aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/controllers/activitypub/client.ts2
-rw-r--r--server/controllers/api/videos/comment.ts7
-rw-r--r--server/controllers/api/videos/token.ts2
-rw-r--r--server/controllers/feeds.ts4
-rw-r--r--server/controllers/tracker.ts34
-rw-r--r--server/helpers/custom-validators/misc.ts8
-rw-r--r--server/helpers/custom-validators/video-captions.ts9
-rw-r--r--server/helpers/custom-validators/video-imports.ts9
-rw-r--r--server/helpers/decache.ts2
-rw-r--r--server/helpers/memoize.ts12
-rw-r--r--server/helpers/youtube-dl/youtube-dl-cli.ts12
-rw-r--r--server/initializers/checker-after-init.ts3
-rw-r--r--server/initializers/checker-before-init.ts1
-rw-r--r--server/initializers/config.ts6
-rw-r--r--server/initializers/constants.ts11
-rw-r--r--server/initializers/installer.ts6
-rw-r--r--server/lib/auth/external-auth.ts72
-rw-r--r--server/lib/auth/oauth-model.ts75
-rw-r--r--server/lib/auth/oauth.ts14
-rw-r--r--server/lib/auth/tokens-cache.ts8
-rw-r--r--server/lib/job-queue/job-queue.ts2
-rw-r--r--server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts51
-rw-r--r--server/lib/opentelemetry/metric-helpers/index.ts1
-rw-r--r--server/lib/opentelemetry/metrics.ts6
-rw-r--r--server/lib/plugins/plugin-helpers-builder.ts6
-rw-r--r--server/lib/redis.ts15
-rw-r--r--server/lib/sync-channel.ts2
-rw-r--r--server/lib/video-comment.ts33
-rw-r--r--server/lib/video-tokens-manager.ts22
-rw-r--r--server/middlewares/sort.ts23
-rw-r--r--server/middlewares/validators/shared/videos.ts12
-rw-r--r--server/models/abuse/abuse-message.ts2
-rw-r--r--server/models/abuse/abuse.ts4
-rw-r--r--server/models/abuse/sql/abuse-query-builder.ts (renamed from server/models/abuse/abuse-query-builder.ts)4
-rw-r--r--server/models/account/account-blocklist.ts2
-rw-r--r--server/models/account/account-video-rate.ts2
-rw-r--r--server/models/account/account.ts16
-rw-r--r--server/models/actor/actor-follow.ts16
-rw-r--r--server/models/actor/actor-image.ts14
-rw-r--r--server/models/actor/actor.ts27
-rw-r--r--server/models/actor/sql/instance-list-followers-query-builder.ts2
-rw-r--r--server/models/actor/sql/instance-list-following-query-builder.ts2
-rw-r--r--server/models/actor/sql/shared/actor-follow-table-attributes.ts65
-rw-r--r--server/models/actor/sql/shared/instance-list-follows-query-builder.ts2
-rw-r--r--server/models/redundancy/video-redundancy.ts2
-rw-r--r--server/models/server/plugin.ts2
-rw-r--r--server/models/server/server-blocklist.ts2
-rw-r--r--server/models/server/server.ts14
-rw-r--r--server/models/shared/index.ts4
-rw-r--r--server/models/shared/model-builder.ts27
-rw-r--r--server/models/shared/model-cache.ts (renamed from server/models/model-cache.ts)0
-rw-r--r--server/models/shared/query.ts75
-rw-r--r--server/models/shared/sequelize-helpers.ts39
-rw-r--r--server/models/shared/sort.ts160
-rw-r--r--server/models/shared/sql.ts68
-rw-r--r--server/models/shared/update.ts14
-rw-r--r--server/models/user/sql/user-notitication-list-query-builder.ts2
-rw-r--r--server/models/user/user-notification-setting.ts2
-rw-r--r--server/models/user/user-notification.ts2
-rw-r--r--server/models/user/user.ts4
-rw-r--r--server/models/utils.ts317
-rw-r--r--server/models/video/sql/comment/video-comment-list-query-builder.ts385
-rw-r--r--server/models/video/sql/comment/video-comment-table-attributes.ts43
-rw-r--r--server/models/video/sql/video/shared/abstract-video-query-builder.ts2
-rw-r--r--server/models/video/sql/video/videos-id-list-query-builder.ts7
-rw-r--r--server/models/video/tag.ts2
-rw-r--r--server/models/video/video-blacklist.ts6
-rw-r--r--server/models/video/video-caption.ts2
-rw-r--r--server/models/video/video-change-ownership.ts2
-rw-r--r--server/models/video/video-channel-sync.ts2
-rw-r--r--server/models/video/video-channel.ts12
-rw-r--r--server/models/video/video-comment.ts458
-rw-r--r--server/models/video/video-file.ts13
-rw-r--r--server/models/video/video-import.ts2
-rw-r--r--server/models/video/video-playlist-element.ts2
-rw-r--r--server/models/video/video-playlist.ts12
-rw-r--r--server/models/video/video-share.ts2
-rw-r--r--server/models/video/video-streaming-playlist.ts7
-rw-r--r--server/models/video/video.ts7
-rw-r--r--server/tests/api/activitypub/cleaner.ts8
-rw-r--r--server/tests/api/check-params/redundancy.ts2
-rw-r--r--server/tests/api/live/live-fast-restream.ts14
-rw-r--r--server/tests/api/notifications/moderation-notifications.ts28
-rw-r--r--server/tests/api/object-storage/video-static-file-privacy.ts4
-rw-r--r--server/tests/api/users/index.ts1
-rw-r--r--server/tests/api/users/oauth.ts192
-rw-r--r--server/tests/api/users/users.ts184
-rw-r--r--server/tests/api/videos/video-channel-syncs.ts3
-rw-r--r--server/tests/api/videos/video-comments.ts26
-rw-r--r--server/tests/external-plugins/auto-block-videos.ts2
-rw-r--r--server/tests/external-plugins/auto-mute.ts2
-rw-r--r--server/tests/feeds/feeds.ts8
-rw-r--r--server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js12
-rw-r--r--server/tests/fixtures/peertube-plugin-test-four/main.js6
-rw-r--r--server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js13
-rw-r--r--server/tests/fixtures/peertube-plugin-test/main.js5
-rw-r--r--server/tests/plugins/external-auth.ts42
-rw-r--r--server/tests/plugins/filter-hooks.ts47
-rw-r--r--server/tests/plugins/id-and-pass-auth.ts34
-rw-r--r--server/tests/plugins/plugin-helpers.ts12
-rw-r--r--server/types/express.d.ts6
-rw-r--r--server/types/lib.d.ts12
-rw-r--r--server/types/plugins/register-server-auth.model.ts21
-rw-r--r--server/types/plugins/register-server-option.model.ts3
104 files changed, 1817 insertions, 1198 deletions
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts
index 8e064fb5b..def320730 100644
--- a/server/controllers/activitypub/client.ts
+++ b/server/controllers/activitypub/client.ts
@@ -309,7 +309,7 @@ async function videoCommentsController (req: express.Request, res: express.Respo
309 if (redirectIfNotOwned(video.url, res)) return 309 if (redirectIfNotOwned(video.url, res)) return
310 310
311 const handler = async (start: number, count: number) => { 311 const handler = async (start: number, count: number) => {
312 const result = await VideoCommentModel.listAndCountByVideoForAP(video, start, count) 312 const result = await VideoCommentModel.listAndCountByVideoForAP({ video, start, count })
313 313
314 return { 314 return {
315 total: result.total, 315 total: result.total,
diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts
index 44d64776c..70ca21500 100644
--- a/server/controllers/api/videos/comment.ts
+++ b/server/controllers/api/videos/comment.ts
@@ -1,4 +1,6 @@
1import { MCommentFormattable } from '@server/types/models'
1import express from 'express' 2import express from 'express'
3
2import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models' 4import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models'
3import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' 5import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
4import { VideoCommentThreads } from '../../../../shared/models/videos/comment/video-comment.model' 6import { VideoCommentThreads } from '../../../../shared/models/videos/comment/video-comment.model'
@@ -109,7 +111,7 @@ async function listVideoThreads (req: express.Request, res: express.Response) {
109 const video = res.locals.onlyVideo 111 const video = res.locals.onlyVideo
110 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined 112 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
111 113
112 let resultList: ThreadsResultList<VideoCommentModel> 114 let resultList: ThreadsResultList<MCommentFormattable>
113 115
114 if (video.commentsEnabled === true) { 116 if (video.commentsEnabled === true) {
115 const apiOptions = await Hooks.wrapObject({ 117 const apiOptions = await Hooks.wrapObject({
@@ -144,12 +146,11 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
144 const video = res.locals.onlyVideo 146 const video = res.locals.onlyVideo
145 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined 147 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
146 148
147 let resultList: ResultList<VideoCommentModel> 149 let resultList: ResultList<MCommentFormattable>
148 150
149 if (video.commentsEnabled === true) { 151 if (video.commentsEnabled === true) {
150 const apiOptions = await Hooks.wrapObject({ 152 const apiOptions = await Hooks.wrapObject({
151 videoId: video.id, 153 videoId: video.id,
152 isVideoOwned: video.isOwned(),
153 threadId: res.locals.videoCommentThread.id, 154 threadId: res.locals.videoCommentThread.id,
154 user 155 user
155 }, 'filter:api.video-thread-comments.list.params') 156 }, 'filter:api.video-thread-comments.list.params')
diff --git a/server/controllers/api/videos/token.ts b/server/controllers/api/videos/token.ts
index 009b6dfb6..22387c3e8 100644
--- a/server/controllers/api/videos/token.ts
+++ b/server/controllers/api/videos/token.ts
@@ -22,7 +22,7 @@ export {
22function generateToken (req: express.Request, res: express.Response) { 22function generateToken (req: express.Request, res: express.Response) {
23 const video = res.locals.onlyVideo 23 const video = res.locals.onlyVideo
24 24
25 const { token, expires } = VideoTokensManager.Instance.create(video.uuid) 25 const { token, expires } = VideoTokensManager.Instance.create({ videoUUID: video.uuid, user: res.locals.oauth.token.User })
26 26
27 return res.json({ 27 return res.json({
28 files: { 28 files: {
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts
index 772fe734d..ef810a842 100644
--- a/server/controllers/feeds.ts
+++ b/server/controllers/feeds.ts
@@ -285,8 +285,8 @@ function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
285 content: toSafeHtml(video.description), 285 content: toSafeHtml(video.description),
286 author: [ 286 author: [
287 { 287 {
288 name: video.VideoChannel.Account.getDisplayName(), 288 name: video.VideoChannel.getDisplayName(),
289 link: video.VideoChannel.Account.Actor.url 289 link: video.VideoChannel.Actor.url
290 } 290 }
291 ], 291 ],
292 date: video.publishedAt, 292 date: video.publishedAt,
diff --git a/server/controllers/tracker.ts b/server/controllers/tracker.ts
index 19a8b2bc9..c4f3a8889 100644
--- a/server/controllers/tracker.ts
+++ b/server/controllers/tracker.ts
@@ -1,17 +1,22 @@
1import { Server as TrackerServer } from 'bittorrent-tracker' 1import { Server as TrackerServer } from 'bittorrent-tracker'
2import express from 'express' 2import express from 'express'
3import { createServer } from 'http' 3import { createServer } from 'http'
4import LRUCache from 'lru-cache'
4import proxyAddr from 'proxy-addr' 5import proxyAddr from 'proxy-addr'
5import { WebSocketServer } from 'ws' 6import { WebSocketServer } from 'ws'
6import { Redis } from '@server/lib/redis'
7import { logger } from '../helpers/logger' 7import { logger } from '../helpers/logger'
8import { CONFIG } from '../initializers/config' 8import { CONFIG } from '../initializers/config'
9import { TRACKER_RATE_LIMITS } from '../initializers/constants' 9import { LRU_CACHE, TRACKER_RATE_LIMITS } from '../initializers/constants'
10import { VideoFileModel } from '../models/video/video-file' 10import { VideoFileModel } from '../models/video/video-file'
11import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' 11import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
12 12
13const trackerRouter = express.Router() 13const trackerRouter = express.Router()
14 14
15const blockedIPs = new LRUCache<string, boolean>({
16 max: LRU_CACHE.TRACKER_IPS.MAX_SIZE,
17 ttl: TRACKER_RATE_LIMITS.BLOCK_IP_LIFETIME
18})
19
15let peersIps = {} 20let peersIps = {}
16let peersIpInfoHash = {} 21let peersIpInfoHash = {}
17runPeersChecker() 22runPeersChecker()
@@ -55,8 +60,7 @@ const trackerServer = new TrackerServer({
55 60
56 // Close socket connection and block IP for a few time 61 // Close socket connection and block IP for a few time
57 if (params.type === 'ws') { 62 if (params.type === 'ws') {
58 Redis.Instance.setTrackerBlockIP(ip) 63 blockedIPs.set(ip, true)
59 .catch(err => logger.error('Cannot set tracker block ip.', { err }))
60 64
61 // setTimeout to wait filter response 65 // setTimeout to wait filter response
62 setTimeout(() => params.socket.close(), 0) 66 setTimeout(() => params.socket.close(), 0)
@@ -102,26 +106,22 @@ function createWebsocketTrackerServer (app: express.Application) {
102 if (request.url === '/tracker/socket') { 106 if (request.url === '/tracker/socket') {
103 const ip = proxyAddr(request, CONFIG.TRUST_PROXY) 107 const ip = proxyAddr(request, CONFIG.TRUST_PROXY)
104 108
105 Redis.Instance.doesTrackerBlockIPExist(ip) 109 if (blockedIPs.has(ip)) {
106 .then(result => { 110 logger.debug('Blocking IP %s from tracker.', ip)
107 if (result === true) {
108 logger.debug('Blocking IP %s from tracker.', ip)
109 111
110 socket.write('HTTP/1.1 403 Forbidden\r\n\r\n') 112 socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
111 socket.destroy() 113 socket.destroy()
112 return 114 return
113 } 115 }
114 116
115 // FIXME: typings 117 // FIXME: typings
116 return wss.handleUpgrade(request, socket as any, head, ws => wss.emit('connection', ws, request)) 118 return wss.handleUpgrade(request, socket as any, head, ws => wss.emit('connection', ws, request))
117 })
118 .catch(err => logger.error('Cannot check if tracker block ip exists.', { err }))
119 } 119 }
120 120
121 // Don't destroy socket, we have Socket.IO too 121 // Don't destroy socket, we have Socket.IO too
122 }) 122 })
123 123
124 return server 124 return { server, trackerServer }
125} 125}
126 126
127// --------------------------------------------------------------------------- 127// ---------------------------------------------------------------------------
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts
index 3dc5504e3..b3ab3ac64 100644
--- a/server/helpers/custom-validators/misc.ts
+++ b/server/helpers/custom-validators/misc.ts
@@ -103,7 +103,13 @@ function checkMimetypeRegex (fileMimeType: string, mimeTypeRegex: string) {
103// --------------------------------------------------------------------------- 103// ---------------------------------------------------------------------------
104 104
105function toCompleteUUID (value: string) { 105function toCompleteUUID (value: string) {
106 if (isShortUUID(value)) return shortToUUID(value) 106 if (isShortUUID(value)) {
107 try {
108 return shortToUUID(value)
109 } catch {
110 return null
111 }
112 }
107 113
108 return value 114 return value
109} 115}
diff --git a/server/helpers/custom-validators/video-captions.ts b/server/helpers/custom-validators/video-captions.ts
index 59ba005fe..d5b09ea03 100644
--- a/server/helpers/custom-validators/video-captions.ts
+++ b/server/helpers/custom-validators/video-captions.ts
@@ -8,10 +8,11 @@ function isVideoCaptionLanguageValid (value: any) {
8 return exists(value) && VIDEO_LANGUAGES[value] !== undefined 8 return exists(value) && VIDEO_LANGUAGES[value] !== undefined
9} 9}
10 10
11const videoCaptionTypesRegex = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT) 11// MacOS sends application/octet-stream
12 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream 12const videoCaptionTypesRegex = [ ...Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT), 'application/octet-stream' ]
13 .map(m => `(${m})`) 13 .map(m => `(${m})`)
14 .join('|') 14 .join('|')
15
15function isVideoCaptionFile (files: UploadFilesForCheck, field: string) { 16function isVideoCaptionFile (files: UploadFilesForCheck, field: string) {
16 return isFileValid({ 17 return isFileValid({
17 files, 18 files,
diff --git a/server/helpers/custom-validators/video-imports.ts b/server/helpers/custom-validators/video-imports.ts
index af93aea56..da8962cb6 100644
--- a/server/helpers/custom-validators/video-imports.ts
+++ b/server/helpers/custom-validators/video-imports.ts
@@ -22,10 +22,11 @@ function isVideoImportStateValid (value: any) {
22 return exists(value) && VIDEO_IMPORT_STATES[value] !== undefined 22 return exists(value) && VIDEO_IMPORT_STATES[value] !== undefined
23} 23}
24 24
25const videoTorrentImportRegex = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT) 25// MacOS sends application/octet-stream
26 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream 26const videoTorrentImportRegex = [ ...Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT), 'application/octet-stream' ]
27 .map(m => `(${m})`) 27 .map(m => `(${m})`)
28 .join('|') 28 .join('|')
29
29function isVideoImportTorrentFile (files: UploadFilesForCheck) { 30function isVideoImportTorrentFile (files: UploadFilesForCheck) {
30 return isFileValid({ 31 return isFileValid({
31 files, 32 files,
diff --git a/server/helpers/decache.ts b/server/helpers/decache.ts
index e31973b7a..08ab545e4 100644
--- a/server/helpers/decache.ts
+++ b/server/helpers/decache.ts
@@ -68,7 +68,7 @@ function searchCache (moduleName: string, callback: (current: NodeModule) => voi
68}; 68};
69 69
70function removeCachedPath (pluginPath: string) { 70function removeCachedPath (pluginPath: string) {
71 const pathCache = (module.constructor as any)._pathCache 71 const pathCache = (module.constructor as any)._pathCache as { [ id: string ]: string[] }
72 72
73 Object.keys(pathCache).forEach(function (cacheKey) { 73 Object.keys(pathCache).forEach(function (cacheKey) {
74 if (cacheKey.includes(pluginPath)) { 74 if (cacheKey.includes(pluginPath)) {
diff --git a/server/helpers/memoize.ts b/server/helpers/memoize.ts
new file mode 100644
index 000000000..aa20e7d73
--- /dev/null
+++ b/server/helpers/memoize.ts
@@ -0,0 +1,12 @@
1import memoizee from 'memoizee'
2
3export function Memoize (config?: memoizee.Options<any>) {
4 return function (_target, _key, descriptor: PropertyDescriptor) {
5 const oldFunction = descriptor.value
6 const newFunction = memoizee(oldFunction, config)
7
8 descriptor.value = function () {
9 return newFunction.apply(this, arguments)
10 }
11 }
12}
diff --git a/server/helpers/youtube-dl/youtube-dl-cli.ts b/server/helpers/youtube-dl/youtube-dl-cli.ts
index a2f630953..765038cea 100644
--- a/server/helpers/youtube-dl/youtube-dl-cli.ts
+++ b/server/helpers/youtube-dl/youtube-dl-cli.ts
@@ -6,6 +6,7 @@ import { VideoResolution } from '@shared/models'
6import { logger, loggerTagsFactory } from '../logger' 6import { logger, loggerTagsFactory } from '../logger'
7import { getProxy, isProxyEnabled } from '../proxy' 7import { getProxy, isProxyEnabled } from '../proxy'
8import { isBinaryResponse, peertubeGot } from '../requests' 8import { isBinaryResponse, peertubeGot } from '../requests'
9import { OptionsOfBufferResponseBody } from 'got/dist/source'
9 10
10const lTags = loggerTagsFactory('youtube-dl') 11const lTags = loggerTagsFactory('youtube-dl')
11 12
@@ -28,7 +29,16 @@ export class YoutubeDLCLI {
28 29
29 logger.info('Updating youtubeDL binary from %s.', url, lTags()) 30 logger.info('Updating youtubeDL binary from %s.', url, lTags())
30 31
31 const gotOptions = { context: { bodyKBLimit: 20_000 }, responseType: 'buffer' as 'buffer' } 32 const gotOptions: OptionsOfBufferResponseBody = {
33 context: { bodyKBLimit: 20_000 },
34 responseType: 'buffer' as 'buffer'
35 }
36
37 if (process.env.YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN) {
38 gotOptions.headers = {
39 authorization: 'Bearer ' + process.env.YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN
40 }
41 }
32 42
33 try { 43 try {
34 let gotResult = await peertubeGot(url, gotOptions) 44 let gotResult = await peertubeGot(url, gotOptions)
diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts
index c83fef425..dc46b5126 100644
--- a/server/initializers/checker-after-init.ts
+++ b/server/initializers/checker-after-init.ts
@@ -174,7 +174,8 @@ function checkRemoteRedundancyConfig () {
174function checkStorageConfig () { 174function checkStorageConfig () {
175 // Check storage directory locations 175 // Check storage directory locations
176 if (isProdInstance()) { 176 if (isProdInstance()) {
177 const configStorage = config.get('storage') 177 const configStorage = config.get<{ [ name: string ]: string }>('storage')
178
178 for (const key of Object.keys(configStorage)) { 179 for (const key of Object.keys(configStorage)) {
179 if (configStorage[key].startsWith('storage/')) { 180 if (configStorage[key].startsWith('storage/')) {
180 logger.warn( 181 logger.warn(
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index 39713a266..57852241c 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -13,6 +13,7 @@ function checkMissedConfig () {
13 'webserver.https', 'webserver.hostname', 'webserver.port', 13 'webserver.https', 'webserver.hostname', 'webserver.port',
14 'secrets.peertube', 14 'secrets.peertube',
15 'trust_proxy', 15 'trust_proxy',
16 'oauth2.token_lifetime.access_token', 'oauth2.token_lifetime.refresh_token',
16 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max', 17 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max',
17 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address', 18 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',
18 'email.body.signature', 'email.subject.prefix', 19 'email.body.signature', 'email.subject.prefix',
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index c2f8b19fd..28aaf36a9 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -149,6 +149,12 @@ const CONFIG = {
149 HOSTNAME: config.get<string>('webserver.hostname'), 149 HOSTNAME: config.get<string>('webserver.hostname'),
150 PORT: config.get<number>('webserver.port') 150 PORT: config.get<number>('webserver.port')
151 }, 151 },
152 OAUTH2: {
153 TOKEN_LIFETIME: {
154 ACCESS_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.access_token')),
155 REFRESH_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.refresh_token'))
156 }
157 },
152 RATES_LIMIT: { 158 RATES_LIMIT: {
153 API: { 159 API: {
154 WINDOW_MS: parseDurationToMs(config.get<string>('rates_limit.api.window')), 160 WINDOW_MS: parseDurationToMs(config.get<string>('rates_limit.api.window')),
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 0e56f0c9f..0dab524d9 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -101,11 +101,6 @@ const SORTABLE_COLUMNS = {
101 VIDEO_REDUNDANCIES: [ 'name' ] 101 VIDEO_REDUNDANCIES: [ 'name' ]
102} 102}
103 103
104const OAUTH_LIFETIME = {
105 ACCESS_TOKEN: 3600 * 24, // 1 day, for upload
106 REFRESH_TOKEN: 1209600 // 2 weeks
107}
108
109const ROUTE_CACHE_LIFETIME = { 104const ROUTE_CACHE_LIFETIME = {
110 FEEDS: '15 minutes', 105 FEEDS: '15 minutes',
111 ROBOTS: '2 hours', 106 ROBOTS: '2 hours',
@@ -781,6 +776,9 @@ const LRU_CACHE = {
781 VIDEO_TOKENS: { 776 VIDEO_TOKENS: {
782 MAX_SIZE: 100_000, 777 MAX_SIZE: 100_000,
783 TTL: parseDurationToMs('8 hours') 778 TTL: parseDurationToMs('8 hours')
779 },
780 TRACKER_IPS: {
781 MAX_SIZE: 100_000
784 } 782 }
785} 783}
786 784
@@ -884,7 +882,7 @@ const TRACKER_RATE_LIMITS = {
884 INTERVAL: 60000 * 5, // 5 minutes 882 INTERVAL: 60000 * 5, // 5 minutes
885 ANNOUNCES_PER_IP_PER_INFOHASH: 15, // maximum announces per torrent in the interval 883 ANNOUNCES_PER_IP_PER_INFOHASH: 15, // maximum announces per torrent in the interval
886 ANNOUNCES_PER_IP: 30, // maximum announces for all our torrents in the interval 884 ANNOUNCES_PER_IP: 30, // maximum announces for all our torrents in the interval
887 BLOCK_IP_LIFETIME: 60000 * 3 // 3 minutes 885 BLOCK_IP_LIFETIME: parseDurationToMs('3 minutes')
888} 886}
889 887
890const P2P_MEDIA_LOADER_PEER_VERSION = 2 888const P2P_MEDIA_LOADER_PEER_VERSION = 2
@@ -1030,7 +1028,6 @@ export {
1030 JOB_ATTEMPTS, 1028 JOB_ATTEMPTS,
1031 AP_CLEANER, 1029 AP_CLEANER,
1032 LAST_MIGRATION_VERSION, 1030 LAST_MIGRATION_VERSION,
1033 OAUTH_LIFETIME,
1034 CUSTOM_HTML_TAG_COMMENTS, 1031 CUSTOM_HTML_TAG_COMMENTS,
1035 STATS_TIMESERIE, 1032 STATS_TIMESERIE,
1036 BROADCAST_CONCURRENCY, 1033 BROADCAST_CONCURRENCY,
diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts
index f5d8eedf1..f48f348a7 100644
--- a/server/initializers/installer.ts
+++ b/server/initializers/installer.ts
@@ -51,8 +51,7 @@ function removeCacheAndTmpDirectories () {
51 const tasks: Promise<any>[] = [] 51 const tasks: Promise<any>[] = []
52 52
53 // Cache directories 53 // Cache directories
54 for (const key of Object.keys(cacheDirectories)) { 54 for (const dir of cacheDirectories) {
55 const dir = cacheDirectories[key]
56 tasks.push(removeDirectoryOrContent(dir)) 55 tasks.push(removeDirectoryOrContent(dir))
57 } 56 }
58 57
@@ -87,8 +86,7 @@ function createDirectoriesIfNotExist () {
87 } 86 }
88 87
89 // Cache directories 88 // Cache directories
90 for (const key of Object.keys(cacheDirectories)) { 89 for (const dir of cacheDirectories) {
91 const dir = cacheDirectories[key]
92 tasks.push(ensureDir(dir)) 90 tasks.push(ensureDir(dir))
93 } 91 }
94 92
diff --git a/server/lib/auth/external-auth.ts b/server/lib/auth/external-auth.ts
index 053112801..bc5b74257 100644
--- a/server/lib/auth/external-auth.ts
+++ b/server/lib/auth/external-auth.ts
@@ -1,26 +1,35 @@
1 1
2import { isUserDisplayNameValid, isUserRoleValid, isUserUsernameValid } from '@server/helpers/custom-validators/users' 2import {
3 isUserAdminFlagsValid,
4 isUserDisplayNameValid,
5 isUserRoleValid,
6 isUserUsernameValid,
7 isUserVideoQuotaDailyValid,
8 isUserVideoQuotaValid
9} from '@server/helpers/custom-validators/users'
3import { logger } from '@server/helpers/logger' 10import { logger } from '@server/helpers/logger'
4import { generateRandomString } from '@server/helpers/utils' 11import { generateRandomString } from '@server/helpers/utils'
5import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants' 12import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants'
6import { PluginManager } from '@server/lib/plugins/plugin-manager' 13import { PluginManager } from '@server/lib/plugins/plugin-manager'
7import { OAuthTokenModel } from '@server/models/oauth/oauth-token' 14import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
15import { MUser } from '@server/types/models'
8import { 16import {
9 RegisterServerAuthenticatedResult, 17 RegisterServerAuthenticatedResult,
10 RegisterServerAuthPassOptions, 18 RegisterServerAuthPassOptions,
11 RegisterServerExternalAuthenticatedResult 19 RegisterServerExternalAuthenticatedResult
12} from '@server/types/plugins/register-server-auth.model' 20} from '@server/types/plugins/register-server-auth.model'
13import { UserRole } from '@shared/models' 21import { UserAdminFlag, UserRole } from '@shared/models'
22import { BypassLogin } from './oauth-model'
23
24export type ExternalUser =
25 Pick<MUser, 'username' | 'email' | 'role' | 'adminFlags' | 'videoQuotaDaily' | 'videoQuota'> &
26 { displayName: string }
14 27
15// Token is the key, expiration date is the value 28// Token is the key, expiration date is the value
16const authBypassTokens = new Map<string, { 29const authBypassTokens = new Map<string, {
17 expires: Date 30 expires: Date
18 user: { 31 user: ExternalUser
19 username: string 32 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
20 email: string
21 displayName: string
22 role: UserRole
23 }
24 authName: string 33 authName: string
25 npmName: string 34 npmName: string
26}>() 35}>()
@@ -56,7 +65,8 @@ async function onExternalUserAuthenticated (options: {
56 expires, 65 expires,
57 user, 66 user,
58 npmName, 67 npmName,
59 authName 68 authName,
69 userUpdater: authResult.userUpdater
60 }) 70 })
61 71
62 // Cleanup expired tokens 72 // Cleanup expired tokens
@@ -78,7 +88,7 @@ async function getAuthNameFromRefreshGrant (refreshToken?: string) {
78 return tokenModel?.authName 88 return tokenModel?.authName
79} 89}
80 90
81async function getBypassFromPasswordGrant (username: string, password: string) { 91async function getBypassFromPasswordGrant (username: string, password: string): Promise<BypassLogin> {
82 const plugins = PluginManager.Instance.getIdAndPassAuths() 92 const plugins = PluginManager.Instance.getIdAndPassAuths()
83 const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = [] 93 const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = []
84 94
@@ -133,7 +143,8 @@ async function getBypassFromPasswordGrant (username: string, password: string) {
133 bypass: true, 143 bypass: true,
134 pluginName: pluginAuth.npmName, 144 pluginName: pluginAuth.npmName,
135 authName: authOptions.authName, 145 authName: authOptions.authName,
136 user: buildUserResult(loginResult) 146 user: buildUserResult(loginResult),
147 userUpdater: loginResult.userUpdater
137 } 148 }
138 } catch (err) { 149 } catch (err) {
139 logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err }) 150 logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err })
@@ -143,7 +154,7 @@ async function getBypassFromPasswordGrant (username: string, password: string) {
143 return undefined 154 return undefined
144} 155}
145 156
146function getBypassFromExternalAuth (username: string, externalAuthToken: string) { 157function getBypassFromExternalAuth (username: string, externalAuthToken: string): BypassLogin {
147 const obj = authBypassTokens.get(externalAuthToken) 158 const obj = authBypassTokens.get(externalAuthToken)
148 if (!obj) throw new Error('Cannot authenticate user with unknown bypass token') 159 if (!obj) throw new Error('Cannot authenticate user with unknown bypass token')
149 160
@@ -167,33 +178,29 @@ function getBypassFromExternalAuth (username: string, externalAuthToken: string)
167 bypass: true, 178 bypass: true,
168 pluginName: npmName, 179 pluginName: npmName,
169 authName, 180 authName,
181 userUpdater: obj.userUpdater,
170 user 182 user
171 } 183 }
172} 184}
173 185
174function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) { 186function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) {
175 if (!isUserUsernameValid(result.username)) { 187 const returnError = (field: string) => {
176 logger.error('Auth method %s of plugin %s did not provide a valid username.', authName, npmName, { username: result.username }) 188 logger.error('Auth method %s of plugin %s did not provide a valid %s.', authName, npmName, field, { [field]: result[field] })
177 return false 189 return false
178 } 190 }
179 191
180 if (!result.email) { 192 if (!isUserUsernameValid(result.username)) return returnError('username')
181 logger.error('Auth method %s of plugin %s did not provide a valid email.', authName, npmName, { email: result.email }) 193 if (!result.email) return returnError('email')
182 return false
183 }
184 194
185 // role is optional 195 // Following fields are optional
186 if (result.role && !isUserRoleValid(result.role)) { 196 if (result.role && !isUserRoleValid(result.role)) return returnError('role')
187 logger.error('Auth method %s of plugin %s did not provide a valid role.', authName, npmName, { role: result.role }) 197 if (result.displayName && !isUserDisplayNameValid(result.displayName)) return returnError('displayName')
188 return false 198 if (result.adminFlags && !isUserAdminFlagsValid(result.adminFlags)) return returnError('adminFlags')
189 } 199 if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota')
200 if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily')
190 201
191 // display name is optional 202 if (result.userUpdater && typeof result.userUpdater !== 'function') {
192 if (result.displayName && !isUserDisplayNameValid(result.displayName)) { 203 logger.error('Auth method %s of plugin %s did not provide a valid user updater function.', authName, npmName)
193 logger.error(
194 'Auth method %s of plugin %s did not provide a valid display name.',
195 authName, npmName, { displayName: result.displayName }
196 )
197 return false 204 return false
198 } 205 }
199 206
@@ -205,7 +212,12 @@ function buildUserResult (pluginResult: RegisterServerAuthenticatedResult) {
205 username: pluginResult.username, 212 username: pluginResult.username,
206 email: pluginResult.email, 213 email: pluginResult.email,
207 role: pluginResult.role ?? UserRole.USER, 214 role: pluginResult.role ?? UserRole.USER,
208 displayName: pluginResult.displayName || pluginResult.username 215 displayName: pluginResult.displayName || pluginResult.username,
216
217 adminFlags: pluginResult.adminFlags ?? UserAdminFlag.NONE,
218
219 videoQuota: pluginResult.videoQuota,
220 videoQuotaDaily: pluginResult.videoQuotaDaily
209 } 221 }
210} 222}
211 223
diff --git a/server/lib/auth/oauth-model.ts b/server/lib/auth/oauth-model.ts
index 322b69e3a..43909284f 100644
--- a/server/lib/auth/oauth-model.ts
+++ b/server/lib/auth/oauth-model.ts
@@ -1,11 +1,13 @@
1import express from 'express' 1import express from 'express'
2import { AccessDeniedError } from '@node-oauth/oauth2-server' 2import { AccessDeniedError } from '@node-oauth/oauth2-server'
3import { PluginManager } from '@server/lib/plugins/plugin-manager' 3import { PluginManager } from '@server/lib/plugins/plugin-manager'
4import { AccountModel } from '@server/models/account/account'
5import { AuthenticatedResultUpdaterFieldName, RegisterServerAuthenticatedResult } from '@server/types'
4import { MOAuthClient } from '@server/types/models' 6import { MOAuthClient } from '@server/types/models'
5import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' 7import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
6import { MUser } from '@server/types/models/user/user' 8import { MUser, MUserDefault } from '@server/types/models/user/user'
7import { pick } from '@shared/core-utils' 9import { pick } from '@shared/core-utils'
8import { UserRole } from '@shared/models/users/user-role' 10import { AttributesOnly } from '@shared/typescript-utils'
9import { logger } from '../../helpers/logger' 11import { logger } from '../../helpers/logger'
10import { CONFIG } from '../../initializers/config' 12import { CONFIG } from '../../initializers/config'
11import { OAuthClientModel } from '../../models/oauth/oauth-client' 13import { OAuthClientModel } from '../../models/oauth/oauth-client'
@@ -13,6 +15,7 @@ import { OAuthTokenModel } from '../../models/oauth/oauth-token'
13import { UserModel } from '../../models/user/user' 15import { UserModel } from '../../models/user/user'
14import { findAvailableLocalActorName } from '../local-actor' 16import { findAvailableLocalActorName } from '../local-actor'
15import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user' 17import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user'
18import { ExternalUser } from './external-auth'
16import { TokensCache } from './tokens-cache' 19import { TokensCache } from './tokens-cache'
17 20
18type TokenInfo = { 21type TokenInfo = {
@@ -26,12 +29,8 @@ export type BypassLogin = {
26 bypass: boolean 29 bypass: boolean
27 pluginName: string 30 pluginName: string
28 authName?: string 31 authName?: string
29 user: { 32 user: ExternalUser
30 username: string 33 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
31 email: string
32 displayName: string
33 role: UserRole
34 }
35} 34}
36 35
37async function getAccessToken (bearerToken: string) { 36async function getAccessToken (bearerToken: string) {
@@ -89,7 +88,9 @@ async function getUser (usernameOrEmail?: string, password?: string, bypassLogin
89 logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName) 88 logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName)
90 89
91 let user = await UserModel.loadByEmail(bypassLogin.user.email) 90 let user = await UserModel.loadByEmail(bypassLogin.user.email)
91
92 if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user) 92 if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user)
93 else user = await updateUserFromExternal(user, bypassLogin.user, bypassLogin.userUpdater)
93 94
94 // Cannot create a user 95 // Cannot create a user
95 if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.') 96 if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.')
@@ -219,16 +220,11 @@ export {
219 220
220// --------------------------------------------------------------------------- 221// ---------------------------------------------------------------------------
221 222
222async function createUserFromExternal (pluginAuth: string, options: { 223async function createUserFromExternal (pluginAuth: string, userOptions: ExternalUser) {
223 username: string 224 const username = await findAvailableLocalActorName(userOptions.username)
224 email: string
225 role: UserRole
226 displayName: string
227}) {
228 const username = await findAvailableLocalActorName(options.username)
229 225
230 const userToCreate = buildUser({ 226 const userToCreate = buildUser({
231 ...pick(options, [ 'email', 'role' ]), 227 ...pick(userOptions, [ 'email', 'role', 'adminFlags', 'videoQuota', 'videoQuotaDaily' ]),
232 228
233 username, 229 username,
234 emailVerified: null, 230 emailVerified: null,
@@ -238,12 +234,57 @@ async function createUserFromExternal (pluginAuth: string, options: {
238 234
239 const { user } = await createUserAccountAndChannelAndPlaylist({ 235 const { user } = await createUserAccountAndChannelAndPlaylist({
240 userToCreate, 236 userToCreate,
241 userDisplayName: options.displayName 237 userDisplayName: userOptions.displayName
242 }) 238 })
243 239
244 return user 240 return user
245} 241}
246 242
243async function updateUserFromExternal (
244 user: MUserDefault,
245 userOptions: ExternalUser,
246 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
247) {
248 if (!userUpdater) return user
249
250 {
251 type UserAttributeKeys = keyof AttributesOnly<UserModel>
252 const mappingKeys: { [ id in UserAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
253 role: 'role',
254 adminFlags: 'adminFlags',
255 videoQuota: 'videoQuota',
256 videoQuotaDaily: 'videoQuotaDaily'
257 }
258
259 for (const modelKey of Object.keys(mappingKeys)) {
260 const pluginOptionKey = mappingKeys[modelKey]
261
262 const newValue = userUpdater({ fieldName: pluginOptionKey, currentValue: user[modelKey], newValue: userOptions[pluginOptionKey] })
263 user.set(modelKey, newValue)
264 }
265 }
266
267 {
268 type AccountAttributeKeys = keyof Partial<AttributesOnly<AccountModel>>
269 const mappingKeys: { [ id in AccountAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
270 name: 'displayName'
271 }
272
273 for (const modelKey of Object.keys(mappingKeys)) {
274 const optionKey = mappingKeys[modelKey]
275
276 const newValue = userUpdater({ fieldName: optionKey, currentValue: user.Account[modelKey], newValue: userOptions[optionKey] })
277 user.Account.set(modelKey, newValue)
278 }
279 }
280
281 logger.debug('Updated user %s with plugin userUpdated function.', user.email, { user, userOptions })
282
283 user.Account = await user.Account.save()
284
285 return user.save()
286}
287
247function checkUserValidityOrThrow (user: MUser) { 288function checkUserValidityOrThrow (user: MUser) {
248 if (user.blocked) throw new AccessDeniedError('User is blocked.') 289 if (user.blocked) throw new AccessDeniedError('User is blocked.')
249} 290}
diff --git a/server/lib/auth/oauth.ts b/server/lib/auth/oauth.ts
index bc0d4301f..2905c79a2 100644
--- a/server/lib/auth/oauth.ts
+++ b/server/lib/auth/oauth.ts
@@ -10,10 +10,11 @@ import OAuth2Server, {
10} from '@node-oauth/oauth2-server' 10} from '@node-oauth/oauth2-server'
11import { randomBytesPromise } from '@server/helpers/core-utils' 11import { randomBytesPromise } from '@server/helpers/core-utils'
12import { isOTPValid } from '@server/helpers/otp' 12import { isOTPValid } from '@server/helpers/otp'
13import { CONFIG } from '@server/initializers/config'
13import { MOAuthClient } from '@server/types/models' 14import { MOAuthClient } from '@server/types/models'
14import { sha1 } from '@shared/extra-utils' 15import { sha1 } from '@shared/extra-utils'
15import { HttpStatusCode } from '@shared/models' 16import { HttpStatusCode } from '@shared/models'
16import { OAUTH_LIFETIME, OTP } from '../../initializers/constants' 17import { OTP } from '../../initializers/constants'
17import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' 18import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
18 19
19class MissingTwoFactorError extends Error { 20class MissingTwoFactorError extends Error {
@@ -32,8 +33,9 @@ class InvalidTwoFactorError extends Error {
32 * 33 *
33 */ 34 */
34const oAuthServer = new OAuth2Server({ 35const oAuthServer = new OAuth2Server({
35 accessTokenLifetime: OAUTH_LIFETIME.ACCESS_TOKEN, 36 // Wants seconds
36 refreshTokenLifetime: OAUTH_LIFETIME.REFRESH_TOKEN, 37 accessTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN / 1000,
38 refreshTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN / 1000,
37 39
38 // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications 40 // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications
39 model: require('./oauth-model') 41 model: require('./oauth-model')
@@ -182,10 +184,10 @@ function generateRandomToken () {
182 184
183function getTokenExpiresAt (type: 'access' | 'refresh') { 185function getTokenExpiresAt (type: 'access' | 'refresh') {
184 const lifetime = type === 'access' 186 const lifetime = type === 'access'
185 ? OAUTH_LIFETIME.ACCESS_TOKEN 187 ? CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN
186 : OAUTH_LIFETIME.REFRESH_TOKEN 188 : CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN
187 189
188 return new Date(Date.now() + lifetime * 1000) 190 return new Date(Date.now() + lifetime)
189} 191}
190 192
191async function buildToken () { 193async function buildToken () {
diff --git a/server/lib/auth/tokens-cache.ts b/server/lib/auth/tokens-cache.ts
index 410708a35..43efc7d02 100644
--- a/server/lib/auth/tokens-cache.ts
+++ b/server/lib/auth/tokens-cache.ts
@@ -36,8 +36,8 @@ export class TokensCache {
36 const token = this.userHavingToken.get(userId) 36 const token = this.userHavingToken.get(userId)
37 37
38 if (token !== undefined) { 38 if (token !== undefined) {
39 this.accessTokenCache.del(token) 39 this.accessTokenCache.delete(token)
40 this.userHavingToken.del(userId) 40 this.userHavingToken.delete(userId)
41 } 41 }
42 } 42 }
43 43
@@ -45,8 +45,8 @@ export class TokensCache {
45 const tokenModel = this.accessTokenCache.get(token) 45 const tokenModel = this.accessTokenCache.get(token)
46 46
47 if (tokenModel !== undefined) { 47 if (tokenModel !== undefined) {
48 this.userHavingToken.del(tokenModel.userId) 48 this.userHavingToken.delete(tokenModel.userId)
49 this.accessTokenCache.del(token) 49 this.accessTokenCache.delete(token)
50 } 50 }
51 } 51 }
52} 52}
diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts
index 866aa1ed0..8597eb000 100644
--- a/server/lib/job-queue/job-queue.ts
+++ b/server/lib/job-queue/job-queue.ts
@@ -184,7 +184,7 @@ class JobQueue {
184 184
185 this.jobRedisPrefix = 'bull-' + WEBSERVER.HOST 185 this.jobRedisPrefix = 'bull-' + WEBSERVER.HOST
186 186
187 for (const handlerName of (Object.keys(handlers) as JobType[])) { 187 for (const handlerName of Object.keys(handlers)) {
188 this.buildWorker(handlerName) 188 this.buildWorker(handlerName)
189 this.buildQueue(handlerName) 189 this.buildQueue(handlerName)
190 this.buildQueueScheduler(handlerName) 190 this.buildQueueScheduler(handlerName)
diff --git a/server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts b/server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts
new file mode 100644
index 000000000..ef40c0fa9
--- /dev/null
+++ b/server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts
@@ -0,0 +1,51 @@
1import { Meter } from '@opentelemetry/api'
2
3export class BittorrentTrackerObserversBuilder {
4
5 constructor (private readonly meter: Meter, private readonly trackerServer: any) {
6
7 }
8
9 buildObservers () {
10 const activeInfohashes = this.meter.createObservableGauge('peertube_bittorrent_tracker_active_infohashes_total', {
11 description: 'Total active infohashes in the PeerTube BitTorrent Tracker'
12 })
13 const inactiveInfohashes = this.meter.createObservableGauge('peertube_bittorrent_tracker_inactive_infohashes_total', {
14 description: 'Total inactive infohashes in the PeerTube BitTorrent Tracker'
15 })
16 const peers = this.meter.createObservableGauge('peertube_bittorrent_tracker_peers_total', {
17 description: 'Total peers in the PeerTube BitTorrent Tracker'
18 })
19
20 this.meter.addBatchObservableCallback(observableResult => {
21 const infohashes = Object.keys(this.trackerServer.torrents)
22
23 const counters = {
24 activeInfohashes: 0,
25 inactiveInfohashes: 0,
26 peers: 0,
27 uncompletedPeers: 0
28 }
29
30 for (const infohash of infohashes) {
31 const content = this.trackerServer.torrents[infohash]
32
33 const peers = content.peers
34 if (peers.keys.length !== 0) counters.activeInfohashes++
35 else counters.inactiveInfohashes++
36
37 for (const peerId of peers.keys) {
38 const peer = peers.peek(peerId)
39 if (peer == null) return
40
41 counters.peers++
42 }
43 }
44
45 observableResult.observe(activeInfohashes, counters.activeInfohashes)
46 observableResult.observe(inactiveInfohashes, counters.inactiveInfohashes)
47 observableResult.observe(peers, counters.peers)
48 }, [ activeInfohashes, inactiveInfohashes, peers ])
49 }
50
51}
diff --git a/server/lib/opentelemetry/metric-helpers/index.ts b/server/lib/opentelemetry/metric-helpers/index.ts
index 775d954ba..47b24a54f 100644
--- a/server/lib/opentelemetry/metric-helpers/index.ts
+++ b/server/lib/opentelemetry/metric-helpers/index.ts
@@ -1,3 +1,4 @@
1export * from './bittorrent-tracker-observers-builder'
1export * from './lives-observers-builder' 2export * from './lives-observers-builder'
2export * from './job-queue-observers-builder' 3export * from './job-queue-observers-builder'
3export * from './nodejs-observers-builder' 4export * from './nodejs-observers-builder'
diff --git a/server/lib/opentelemetry/metrics.ts b/server/lib/opentelemetry/metrics.ts
index 226d514c0..9cc067e4a 100644
--- a/server/lib/opentelemetry/metrics.ts
+++ b/server/lib/opentelemetry/metrics.ts
@@ -7,6 +7,7 @@ import { CONFIG } from '@server/initializers/config'
7import { MVideoImmutable } from '@server/types/models' 7import { MVideoImmutable } from '@server/types/models'
8import { PlaybackMetricCreate } from '@shared/models' 8import { PlaybackMetricCreate } from '@shared/models'
9import { 9import {
10 BittorrentTrackerObserversBuilder,
10 JobQueueObserversBuilder, 11 JobQueueObserversBuilder,
11 LivesObserversBuilder, 12 LivesObserversBuilder,
12 NodeJSObserversBuilder, 13 NodeJSObserversBuilder,
@@ -41,7 +42,7 @@ class OpenTelemetryMetrics {
41 }) 42 })
42 } 43 }
43 44
44 registerMetrics () { 45 registerMetrics (options: { trackerServer: any }) {
45 if (CONFIG.OPEN_TELEMETRY.METRICS.ENABLED !== true) return 46 if (CONFIG.OPEN_TELEMETRY.METRICS.ENABLED !== true) return
46 47
47 logger.info('Registering Open Telemetry metrics') 48 logger.info('Registering Open Telemetry metrics')
@@ -80,6 +81,9 @@ class OpenTelemetryMetrics {
80 81
81 const viewersObserversBuilder = new ViewersObserversBuilder(this.meter) 82 const viewersObserversBuilder = new ViewersObserversBuilder(this.meter)
82 viewersObserversBuilder.buildObservers() 83 viewersObserversBuilder.buildObservers()
84
85 const bittorrentTrackerObserversBuilder = new BittorrentTrackerObserversBuilder(this.meter, options.trackerServer)
86 bittorrentTrackerObserversBuilder.buildObservers()
83 } 87 }
84 88
85 observePlaybackMetric (video: MVideoImmutable, metrics: PlaybackMetricCreate) { 89 observePlaybackMetric (video: MVideoImmutable, metrics: PlaybackMetricCreate) {
diff --git a/server/lib/plugins/plugin-helpers-builder.ts b/server/lib/plugins/plugin-helpers-builder.ts
index 7b1def6e3..66383af46 100644
--- a/server/lib/plugins/plugin-helpers-builder.ts
+++ b/server/lib/plugins/plugin-helpers-builder.ts
@@ -209,6 +209,10 @@ function buildConfigHelpers () {
209 return WEBSERVER.URL 209 return WEBSERVER.URL
210 }, 210 },
211 211
212 getServerListeningConfig () {
213 return { hostname: CONFIG.LISTEN.HOSTNAME, port: CONFIG.LISTEN.PORT }
214 },
215
212 getServerConfig () { 216 getServerConfig () {
213 return ServerConfigManager.Instance.getServerConfig() 217 return ServerConfigManager.Instance.getServerConfig()
214 } 218 }
@@ -245,7 +249,7 @@ function buildUserHelpers () {
245 }, 249 },
246 250
247 getAuthUser: (res: express.Response) => { 251 getAuthUser: (res: express.Response) => {
248 const user = res.locals.oauth?.token?.User 252 const user = res.locals.oauth?.token?.User || res.locals.videoFileToken?.user
249 if (!user) return undefined 253 if (!user) return undefined
250 254
251 return UserModel.loadByIdFull(user.id) 255 return UserModel.loadByIdFull(user.id)
diff --git a/server/lib/redis.ts b/server/lib/redis.ts
index c0e9aece7..451ddd0b6 100644
--- a/server/lib/redis.ts
+++ b/server/lib/redis.ts
@@ -8,7 +8,6 @@ import {
8 AP_CLEANER, 8 AP_CLEANER,
9 CONTACT_FORM_LIFETIME, 9 CONTACT_FORM_LIFETIME,
10 RESUMABLE_UPLOAD_SESSION_LIFETIME, 10 RESUMABLE_UPLOAD_SESSION_LIFETIME,
11 TRACKER_RATE_LIMITS,
12 TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME, 11 TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
13 USER_EMAIL_VERIFY_LIFETIME, 12 USER_EMAIL_VERIFY_LIFETIME,
14 USER_PASSWORD_CREATE_LIFETIME, 13 USER_PASSWORD_CREATE_LIFETIME,
@@ -157,16 +156,6 @@ class Redis {
157 return this.exists(this.generateIPViewKey(ip, videoUUID)) 156 return this.exists(this.generateIPViewKey(ip, videoUUID))
158 } 157 }
159 158
160 /* ************ Tracker IP block ************ */
161
162 setTrackerBlockIP (ip: string) {
163 return this.setValue(this.generateTrackerBlockIPKey(ip), '1', TRACKER_RATE_LIMITS.BLOCK_IP_LIFETIME)
164 }
165
166 async doesTrackerBlockIPExist (ip: string) {
167 return this.exists(this.generateTrackerBlockIPKey(ip))
168 }
169
170 /* ************ Video views stats ************ */ 159 /* ************ Video views stats ************ */
171 160
172 addVideoViewStats (videoId: number) { 161 addVideoViewStats (videoId: number) {
@@ -365,10 +354,6 @@ class Redis {
365 return `views-${videoUUID}-${ip}` 354 return `views-${videoUUID}-${ip}`
366 } 355 }
367 356
368 private generateTrackerBlockIPKey (ip: string) {
369 return `tracker-block-ip-${ip}`
370 }
371
372 private generateContactFormKey (ip: string) { 357 private generateContactFormKey (ip: string) {
373 return 'contact-form-' + ip 358 return 'contact-form-' + ip
374 } 359 }
diff --git a/server/lib/sync-channel.ts b/server/lib/sync-channel.ts
index 10167ee38..3a805a943 100644
--- a/server/lib/sync-channel.ts
+++ b/server/lib/sync-channel.ts
@@ -76,7 +76,7 @@ export async function synchronizeChannel (options: {
76 76
77 await JobQueue.Instance.createJobWithChildren(parent, children) 77 await JobQueue.Instance.createJobWithChildren(parent, children)
78 } catch (err) { 78 } catch (err) {
79 logger.error(`Failed to import channel ${channel.name}`, { err }) 79 logger.error(`Failed to import ${externalChannelUrl} in channel ${channel.name}`, { err })
80 channelSync.state = VideoChannelSyncState.FAILED 80 channelSync.state = VideoChannelSyncState.FAILED
81 await channelSync.save() 81 await channelSync.save()
82 } 82 }
diff --git a/server/lib/video-comment.ts b/server/lib/video-comment.ts
index 02f160fe8..6eb865f7f 100644
--- a/server/lib/video-comment.ts
+++ b/server/lib/video-comment.ts
@@ -1,30 +1,41 @@
1import express from 'express'
1import { cloneDeep } from 'lodash' 2import { cloneDeep } from 'lodash'
2import * as Sequelize from 'sequelize' 3import * as Sequelize from 'sequelize'
3import express from 'express'
4import { logger } from '@server/helpers/logger' 4import { logger } from '@server/helpers/logger'
5import { sequelizeTypescript } from '@server/initializers/database' 5import { sequelizeTypescript } from '@server/initializers/database'
6import { ResultList } from '../../shared/models' 6import { ResultList } from '../../shared/models'
7import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model' 7import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model'
8import { VideoCommentModel } from '../models/video/video-comment' 8import { VideoCommentModel } from '../models/video/video-comment'
9import { MAccountDefault, MComment, MCommentOwnerVideo, MCommentOwnerVideoReply, MVideoFullLight } from '../types/models' 9import {
10 MAccountDefault,
11 MComment,
12 MCommentFormattable,
13 MCommentOwnerVideo,
14 MCommentOwnerVideoReply,
15 MVideoFullLight
16} from '../types/models'
10import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send' 17import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send'
11import { getLocalVideoCommentActivityPubUrl } from './activitypub/url' 18import { getLocalVideoCommentActivityPubUrl } from './activitypub/url'
12import { Hooks } from './plugins/hooks' 19import { Hooks } from './plugins/hooks'
13 20
14async function removeComment (videoCommentInstance: MCommentOwnerVideo, req: express.Request, res: express.Response) { 21async function removeComment (commentArg: MComment, req: express.Request, res: express.Response) {
15 const videoCommentInstanceBefore = cloneDeep(videoCommentInstance) 22 let videoCommentInstanceBefore: MCommentOwnerVideo
16 23
17 await sequelizeTypescript.transaction(async t => { 24 await sequelizeTypescript.transaction(async t => {
18 if (videoCommentInstance.isOwned() || videoCommentInstance.Video.isOwned()) { 25 const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(commentArg.url, t)
19 await sendDeleteVideoComment(videoCommentInstance, t) 26
27 videoCommentInstanceBefore = cloneDeep(comment)
28
29 if (comment.isOwned() || comment.Video.isOwned()) {
30 await sendDeleteVideoComment(comment, t)
20 } 31 }
21 32
22 videoCommentInstance.markAsDeleted() 33 comment.markAsDeleted()
23 34
24 await videoCommentInstance.save({ transaction: t }) 35 await comment.save({ transaction: t })
25 })
26 36
27 logger.info('Video comment %d deleted.', videoCommentInstance.id) 37 logger.info('Video comment %d deleted.', comment.id)
38 })
28 39
29 Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore, req, res }) 40 Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore, req, res })
30} 41}
@@ -64,7 +75,7 @@ async function createVideoComment (obj: {
64 return savedComment 75 return savedComment
65} 76}
66 77
67function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>): VideoCommentThreadTree { 78function buildFormattedCommentTree (resultList: ResultList<MCommentFormattable>): VideoCommentThreadTree {
68 // Comments are sorted by id ASC 79 // Comments are sorted by id ASC
69 const comments = resultList.data 80 const comments = resultList.data
70 81
diff --git a/server/lib/video-tokens-manager.ts b/server/lib/video-tokens-manager.ts
index c43085d16..17aa29cdd 100644
--- a/server/lib/video-tokens-manager.ts
+++ b/server/lib/video-tokens-manager.ts
@@ -1,5 +1,7 @@
1import LRUCache from 'lru-cache' 1import LRUCache from 'lru-cache'
2import { LRU_CACHE } from '@server/initializers/constants' 2import { LRU_CACHE } from '@server/initializers/constants'
3import { MUserAccountUrl } from '@server/types/models'
4import { pick } from '@shared/core-utils'
3import { buildUUID } from '@shared/extra-utils' 5import { buildUUID } from '@shared/extra-utils'
4 6
5// --------------------------------------------------------------------------- 7// ---------------------------------------------------------------------------
@@ -10,19 +12,22 @@ class VideoTokensManager {
10 12
11 private static instance: VideoTokensManager 13 private static instance: VideoTokensManager
12 14
13 private readonly lruCache = new LRUCache<string, string>({ 15 private readonly lruCache = new LRUCache<string, { videoUUID: string, user: MUserAccountUrl }>({
14 max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE, 16 max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE,
15 ttl: LRU_CACHE.VIDEO_TOKENS.TTL 17 ttl: LRU_CACHE.VIDEO_TOKENS.TTL
16 }) 18 })
17 19
18 private constructor () {} 20 private constructor () {}
19 21
20 create (videoUUID: string) { 22 create (options: {
23 user: MUserAccountUrl
24 videoUUID: string
25 }) {
21 const token = buildUUID() 26 const token = buildUUID()
22 27
23 const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL) 28 const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
24 29
25 this.lruCache.set(token, videoUUID) 30 this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ]))
26 31
27 return { token, expires } 32 return { token, expires }
28 } 33 }
@@ -34,7 +39,16 @@ class VideoTokensManager {
34 const value = this.lruCache.get(options.token) 39 const value = this.lruCache.get(options.token)
35 if (!value) return false 40 if (!value) return false
36 41
37 return value === options.videoUUID 42 return value.videoUUID === options.videoUUID
43 }
44
45 getUserFromToken (options: {
46 token: string
47 }) {
48 const value = this.lruCache.get(options.token)
49 if (!value) return undefined
50
51 return value.user
38 } 52 }
39 53
40 static get Instance () { 54 static get Instance () {
diff --git a/server/middlewares/sort.ts b/server/middlewares/sort.ts
index 458895898..77a532276 100644
--- a/server/middlewares/sort.ts
+++ b/server/middlewares/sort.ts
@@ -1,5 +1,4 @@
1import express from 'express' 1import express from 'express'
2import { SortType } from '../models/utils'
3 2
4const setDefaultSort = setDefaultSortFactory('-createdAt') 3const setDefaultSort = setDefaultSortFactory('-createdAt')
5const setDefaultVideosSort = setDefaultSortFactory('-publishedAt') 4const setDefaultVideosSort = setDefaultSortFactory('-publishedAt')
@@ -7,27 +6,7 @@ const setDefaultVideosSort = setDefaultSortFactory('-publishedAt')
7const setDefaultVideoRedundanciesSort = setDefaultSortFactory('name') 6const setDefaultVideoRedundanciesSort = setDefaultSortFactory('name')
8 7
9const setDefaultSearchSort = setDefaultSortFactory('-match') 8const setDefaultSearchSort = setDefaultSortFactory('-match')
10 9const setBlacklistSort = setDefaultSortFactory('-createdAt')
11function setBlacklistSort (req: express.Request, res: express.Response, next: express.NextFunction) {
12 const newSort: SortType = { sortModel: undefined, sortValue: '' }
13
14 if (!req.query.sort) req.query.sort = '-createdAt'
15
16 // Set model we want to sort onto
17 if (req.query.sort === '-createdAt' || req.query.sort === 'createdAt' ||
18 req.query.sort === '-id' || req.query.sort === 'id') {
19 // If we want to sort onto the BlacklistedVideos relation, we won't specify it in the query parameter...
20 newSort.sortModel = undefined
21 } else {
22 newSort.sortModel = 'Video'
23 }
24
25 newSort.sortValue = req.query.sort
26
27 req.query.sort = newSort
28
29 return next()
30}
31 10
32// --------------------------------------------------------------------------- 11// ---------------------------------------------------------------------------
33 12
diff --git a/server/middlewares/validators/shared/videos.ts b/server/middlewares/validators/shared/videos.ts
index ebbfc0a0a..0033a32ff 100644
--- a/server/middlewares/validators/shared/videos.ts
+++ b/server/middlewares/validators/shared/videos.ts
@@ -180,18 +180,16 @@ async function checkCanAccessVideoStaticFiles (options: {
180 return checkCanSeeVideo(options) 180 return checkCanSeeVideo(options)
181 } 181 }
182 182
183 if (!video.hasPrivateStaticPath()) return true
184
185 const videoFileToken = req.query.videoFileToken 183 const videoFileToken = req.query.videoFileToken
186 if (!videoFileToken) { 184 if (videoFileToken && VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) {
187 res.sendStatus(HttpStatusCode.FORBIDDEN_403) 185 const user = VideoTokensManager.Instance.getUserFromToken({ token: videoFileToken })
188 return false
189 }
190 186
191 if (VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) { 187 res.locals.videoFileToken = { user }
192 return true 188 return true
193 } 189 }
194 190
191 if (!video.hasPrivateStaticPath()) return true
192
195 res.sendStatus(HttpStatusCode.FORBIDDEN_403) 193 res.sendStatus(HttpStatusCode.FORBIDDEN_403)
196 return false 194 return false
197} 195}
diff --git a/server/models/abuse/abuse-message.ts b/server/models/abuse/abuse-message.ts
index 20008768b..14a5bffa2 100644
--- a/server/models/abuse/abuse-message.ts
+++ b/server/models/abuse/abuse-message.ts
@@ -5,7 +5,7 @@ import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models'
5import { AbuseMessage } from '@shared/models' 5import { AbuseMessage } from '@shared/models'
6import { AttributesOnly } from '@shared/typescript-utils' 6import { AttributesOnly } from '@shared/typescript-utils'
7import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account' 7import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
8import { getSort, throwIfNotValid } from '../utils' 8import { getSort, throwIfNotValid } from '../shared'
9import { AbuseModel } from './abuse' 9import { AbuseModel } from './abuse'
10 10
11@Table({ 11@Table({
diff --git a/server/models/abuse/abuse.ts b/server/models/abuse/abuse.ts
index 4c6a96a86..4ce40bf2f 100644
--- a/server/models/abuse/abuse.ts
+++ b/server/models/abuse/abuse.ts
@@ -34,13 +34,13 @@ import { AttributesOnly } from '@shared/typescript-utils'
34import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants' 34import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
35import { MAbuseAdminFormattable, MAbuseAP, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models' 35import { MAbuseAdminFormattable, MAbuseAP, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models'
36import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account' 36import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
37import { getSort, throwIfNotValid } from '../utils' 37import { getSort, throwIfNotValid } from '../shared'
38import { ThumbnailModel } from '../video/thumbnail' 38import { ThumbnailModel } from '../video/thumbnail'
39import { ScopeNames as VideoScopeNames, VideoModel } from '../video/video' 39import { ScopeNames as VideoScopeNames, VideoModel } from '../video/video'
40import { VideoBlacklistModel } from '../video/video-blacklist' 40import { VideoBlacklistModel } from '../video/video-blacklist'
41import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel' 41import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel'
42import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment' 42import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment'
43import { buildAbuseListQuery, BuildAbusesQueryOptions } from './abuse-query-builder' 43import { buildAbuseListQuery, BuildAbusesQueryOptions } from './sql/abuse-query-builder'
44import { VideoAbuseModel } from './video-abuse' 44import { VideoAbuseModel } from './video-abuse'
45import { VideoCommentAbuseModel } from './video-comment-abuse' 45import { VideoCommentAbuseModel } from './video-comment-abuse'
46 46
diff --git a/server/models/abuse/abuse-query-builder.ts b/server/models/abuse/sql/abuse-query-builder.ts
index 74f4542e5..282d4541a 100644
--- a/server/models/abuse/abuse-query-builder.ts
+++ b/server/models/abuse/sql/abuse-query-builder.ts
@@ -2,7 +2,7 @@
2import { exists } from '@server/helpers/custom-validators/misc' 2import { exists } from '@server/helpers/custom-validators/misc'
3import { forceNumber } from '@shared/core-utils' 3import { forceNumber } from '@shared/core-utils'
4import { AbuseFilter, AbuseState, AbuseVideoIs } from '@shared/models' 4import { AbuseFilter, AbuseState, AbuseVideoIs } from '@shared/models'
5import { buildBlockedAccountSQL, buildDirectionAndField } from '../utils' 5import { buildBlockedAccountSQL, buildSortDirectionAndField } from '../../shared'
6 6
7export type BuildAbusesQueryOptions = { 7export type BuildAbusesQueryOptions = {
8 start: number 8 start: number
@@ -157,7 +157,7 @@ function buildAbuseListQuery (options: BuildAbusesQueryOptions, type: 'count' |
157} 157}
158 158
159function buildAbuseOrder (value: string) { 159function buildAbuseOrder (value: string) {
160 const { direction, field } = buildDirectionAndField(value) 160 const { direction, field } = buildSortDirectionAndField(value)
161 161
162 return `ORDER BY "abuse"."${field}" ${direction}` 162 return `ORDER BY "abuse"."${field}" ${direction}`
163} 163}
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts
index 377249b38..f6212ff6e 100644
--- a/server/models/account/account-blocklist.ts
+++ b/server/models/account/account-blocklist.ts
@@ -6,7 +6,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
6import { AccountBlock } from '../../../shared/models' 6import { AccountBlock } from '../../../shared/models'
7import { ActorModel } from '../actor/actor' 7import { ActorModel } from '../actor/actor'
8import { ServerModel } from '../server/server' 8import { ServerModel } from '../server/server'
9import { createSafeIn, getSort, searchAttribute } from '../utils' 9import { createSafeIn, getSort, searchAttribute } from '../shared'
10import { AccountModel } from './account' 10import { AccountModel } from './account'
11 11
12@Table({ 12@Table({
diff --git a/server/models/account/account-video-rate.ts b/server/models/account/account-video-rate.ts
index 7afc907da..9e7ef4394 100644
--- a/server/models/account/account-video-rate.ts
+++ b/server/models/account/account-video-rate.ts
@@ -11,7 +11,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
11import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 11import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
12import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants' 12import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants'
13import { ActorModel } from '../actor/actor' 13import { ActorModel } from '../actor/actor'
14import { getSort, throwIfNotValid } from '../utils' 14import { getSort, throwIfNotValid } from '../shared'
15import { VideoModel } from '../video/video' 15import { VideoModel } from '../video/video'
16import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel' 16import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel'
17import { AccountModel } from './account' 17import { AccountModel } from './account'
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index 8a7dfba94..dc989417b 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -16,7 +16,7 @@ import {
16 Table, 16 Table,
17 UpdatedAt 17 UpdatedAt
18} from 'sequelize-typescript' 18} from 'sequelize-typescript'
19import { ModelCache } from '@server/models/model-cache' 19import { ModelCache } from '@server/models/shared/model-cache'
20import { AttributesOnly } from '@shared/typescript-utils' 20import { AttributesOnly } from '@shared/typescript-utils'
21import { Account, AccountSummary } from '../../../shared/models/actors' 21import { Account, AccountSummary } from '../../../shared/models/actors'
22import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts' 22import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
@@ -38,7 +38,7 @@ import { ApplicationModel } from '../application/application'
38import { ServerModel } from '../server/server' 38import { ServerModel } from '../server/server'
39import { ServerBlocklistModel } from '../server/server-blocklist' 39import { ServerBlocklistModel } from '../server/server-blocklist'
40import { UserModel } from '../user/user' 40import { UserModel } from '../user/user'
41import { getSort, throwIfNotValid } from '../utils' 41import { buildSQLAttributes, getSort, throwIfNotValid } from '../shared'
42import { VideoModel } from '../video/video' 42import { VideoModel } from '../video/video'
43import { VideoChannelModel } from '../video/video-channel' 43import { VideoChannelModel } from '../video/video-channel'
44import { VideoCommentModel } from '../video/video-comment' 44import { VideoCommentModel } from '../video/video-comment'
@@ -251,6 +251,18 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
251 return undefined 251 return undefined
252 } 252 }
253 253
254 // ---------------------------------------------------------------------------
255
256 static getSQLAttributes (tableName: string, aliasPrefix = '') {
257 return buildSQLAttributes({
258 model: this,
259 tableName,
260 aliasPrefix
261 })
262 }
263
264 // ---------------------------------------------------------------------------
265
254 static load (id: number, transaction?: Transaction): Promise<MAccountDefault> { 266 static load (id: number, transaction?: Transaction): Promise<MAccountDefault> {
255 return AccountModel.findByPk(id, { transaction }) 267 return AccountModel.findByPk(id, { transaction })
256 } 268 }
diff --git a/server/models/actor/actor-follow.ts b/server/models/actor/actor-follow.ts
index 9615229dd..32e5d78b0 100644
--- a/server/models/actor/actor-follow.ts
+++ b/server/models/actor/actor-follow.ts
@@ -38,7 +38,7 @@ import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAM
38import { AccountModel } from '../account/account' 38import { AccountModel } from '../account/account'
39import { ServerModel } from '../server/server' 39import { ServerModel } from '../server/server'
40import { doesExist } from '../shared/query' 40import { doesExist } from '../shared/query'
41import { createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../utils' 41import { buildSQLAttributes, createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../shared'
42import { VideoChannelModel } from '../video/video-channel' 42import { VideoChannelModel } from '../video/video-channel'
43import { ActorModel, unusedActorAttributesForAPI } from './actor' 43import { ActorModel, unusedActorAttributesForAPI } from './actor'
44import { InstanceListFollowersQueryBuilder, ListFollowersOptions } from './sql/instance-list-followers-query-builder' 44import { InstanceListFollowersQueryBuilder, ListFollowersOptions } from './sql/instance-list-followers-query-builder'
@@ -140,6 +140,18 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
140 }) 140 })
141 } 141 }
142 142
143 // ---------------------------------------------------------------------------
144
145 static getSQLAttributes (tableName: string, aliasPrefix = '') {
146 return buildSQLAttributes({
147 model: this,
148 tableName,
149 aliasPrefix
150 })
151 }
152
153 // ---------------------------------------------------------------------------
154
143 /* 155 /*
144 * @deprecated Use `findOrCreateCustom` instead 156 * @deprecated Use `findOrCreateCustom` instead
145 */ 157 */
@@ -213,7 +225,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
213 `WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId AND "state" = 'accepted' ` + 225 `WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId AND "state" = 'accepted' ` +
214 `LIMIT 1` 226 `LIMIT 1`
215 227
216 return doesExist(query, { actorId, followerActorId }) 228 return doesExist(this.sequelize, query, { actorId, followerActorId })
217 } 229 }
218 230
219 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> { 231 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> {
diff --git a/server/models/actor/actor-image.ts b/server/models/actor/actor-image.ts
index f2b3b2f4b..9c34a0101 100644
--- a/server/models/actor/actor-image.ts
+++ b/server/models/actor/actor-image.ts
@@ -22,7 +22,7 @@ import { isActivityPubUrlValid } from '../../helpers/custom-validators/activityp
22import { logger } from '../../helpers/logger' 22import { logger } from '../../helpers/logger'
23import { CONFIG } from '../../initializers/config' 23import { CONFIG } from '../../initializers/config'
24import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants' 24import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants'
25import { throwIfNotValid } from '../utils' 25import { buildSQLAttributes, throwIfNotValid } from '../shared'
26import { ActorModel } from './actor' 26import { ActorModel } from './actor'
27 27
28@Table({ 28@Table({
@@ -94,6 +94,18 @@ export class ActorImageModel extends Model<Partial<AttributesOnly<ActorImageMode
94 .catch(err => logger.error('Cannot remove actor image file %s.', instance.filename, { err })) 94 .catch(err => logger.error('Cannot remove actor image file %s.', instance.filename, { err }))
95 } 95 }
96 96
97 // ---------------------------------------------------------------------------
98
99 static getSQLAttributes (tableName: string, aliasPrefix = '') {
100 return buildSQLAttributes({
101 model: this,
102 tableName,
103 aliasPrefix
104 })
105 }
106
107 // ---------------------------------------------------------------------------
108
97 static loadByName (filename: string) { 109 static loadByName (filename: string) {
98 const query = { 110 const query = {
99 where: { 111 where: {
diff --git a/server/models/actor/actor.ts b/server/models/actor/actor.ts
index d7afa727d..1432e8757 100644
--- a/server/models/actor/actor.ts
+++ b/server/models/actor/actor.ts
@@ -17,7 +17,7 @@ import {
17} from 'sequelize-typescript' 17} from 'sequelize-typescript'
18import { activityPubContextify } from '@server/lib/activitypub/context' 18import { activityPubContextify } from '@server/lib/activitypub/context'
19import { getBiggestActorImage } from '@server/lib/actor-image' 19import { getBiggestActorImage } from '@server/lib/actor-image'
20import { ModelCache } from '@server/models/model-cache' 20import { ModelCache } from '@server/models/shared/model-cache'
21import { forceNumber, getLowercaseExtension } from '@shared/core-utils' 21import { forceNumber, getLowercaseExtension } from '@shared/core-utils'
22import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models' 22import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models'
23import { AttributesOnly } from '@shared/typescript-utils' 23import { AttributesOnly } from '@shared/typescript-utils'
@@ -55,7 +55,7 @@ import {
55import { AccountModel } from '../account/account' 55import { AccountModel } from '../account/account'
56import { getServerActor } from '../application/application' 56import { getServerActor } from '../application/application'
57import { ServerModel } from '../server/server' 57import { ServerModel } from '../server/server'
58import { isOutdated, throwIfNotValid } from '../utils' 58import { buildSQLAttributes, isOutdated, throwIfNotValid } from '../shared'
59import { VideoModel } from '../video/video' 59import { VideoModel } from '../video/video'
60import { VideoChannelModel } from '../video/video-channel' 60import { VideoChannelModel } from '../video/video-channel'
61import { ActorFollowModel } from './actor-follow' 61import { ActorFollowModel } from './actor-follow'
@@ -65,7 +65,7 @@ enum ScopeNames {
65 FULL = 'FULL' 65 FULL = 'FULL'
66} 66}
67 67
68export const unusedActorAttributesForAPI = [ 68export const unusedActorAttributesForAPI: (keyof AttributesOnly<ActorModel>)[] = [
69 'publicKey', 69 'publicKey',
70 'privateKey', 70 'privateKey',
71 'inboxUrl', 71 'inboxUrl',
@@ -306,6 +306,27 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
306 }) 306 })
307 VideoChannel: VideoChannelModel 307 VideoChannel: VideoChannelModel
308 308
309 // ---------------------------------------------------------------------------
310
311 static getSQLAttributes (tableName: string, aliasPrefix = '') {
312 return buildSQLAttributes({
313 model: this,
314 tableName,
315 aliasPrefix
316 })
317 }
318
319 static getSQLAPIAttributes (tableName: string, aliasPrefix = '') {
320 return buildSQLAttributes({
321 model: this,
322 tableName,
323 aliasPrefix,
324 excludeAttributes: unusedActorAttributesForAPI
325 })
326 }
327
328 // ---------------------------------------------------------------------------
329
309 static async load (id: number): Promise<MActor> { 330 static async load (id: number): Promise<MActor> {
310 const actorServer = await getServerActor() 331 const actorServer = await getServerActor()
311 if (id === actorServer.id) return actorServer 332 if (id === actorServer.id) return actorServer
diff --git a/server/models/actor/sql/instance-list-followers-query-builder.ts b/server/models/actor/sql/instance-list-followers-query-builder.ts
index 4a17a8f11..34ce29b5d 100644
--- a/server/models/actor/sql/instance-list-followers-query-builder.ts
+++ b/server/models/actor/sql/instance-list-followers-query-builder.ts
@@ -1,8 +1,8 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import { ModelBuilder } from '@server/models/shared' 2import { ModelBuilder } from '@server/models/shared'
3import { parseRowCountResult } from '@server/models/utils'
4import { MActorFollowActorsDefault } from '@server/types/models' 3import { MActorFollowActorsDefault } from '@server/types/models'
5import { ActivityPubActorType, FollowState } from '@shared/models' 4import { ActivityPubActorType, FollowState } from '@shared/models'
5import { parseRowCountResult } from '../../shared'
6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder' 6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
7 7
8export interface ListFollowersOptions { 8export interface ListFollowersOptions {
diff --git a/server/models/actor/sql/instance-list-following-query-builder.ts b/server/models/actor/sql/instance-list-following-query-builder.ts
index 880170b85..77b4e3dce 100644
--- a/server/models/actor/sql/instance-list-following-query-builder.ts
+++ b/server/models/actor/sql/instance-list-following-query-builder.ts
@@ -1,8 +1,8 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import { ModelBuilder } from '@server/models/shared' 2import { ModelBuilder } from '@server/models/shared'
3import { parseRowCountResult } from '@server/models/utils'
4import { MActorFollowActorsDefault } from '@server/types/models' 3import { MActorFollowActorsDefault } from '@server/types/models'
5import { ActivityPubActorType, FollowState } from '@shared/models' 4import { ActivityPubActorType, FollowState } from '@shared/models'
5import { parseRowCountResult } from '../../shared'
6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder' 6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
7 7
8export interface ListFollowingOptions { 8export interface ListFollowingOptions {
diff --git a/server/models/actor/sql/shared/actor-follow-table-attributes.ts b/server/models/actor/sql/shared/actor-follow-table-attributes.ts
index 156b37d44..7dd908ece 100644
--- a/server/models/actor/sql/shared/actor-follow-table-attributes.ts
+++ b/server/models/actor/sql/shared/actor-follow-table-attributes.ts
@@ -1,62 +1,31 @@
1import { logger } from '@server/helpers/logger'
2import { Memoize } from '@server/helpers/memoize'
3import { ServerModel } from '@server/models/server/server'
4import { ActorModel } from '../../actor'
5import { ActorFollowModel } from '../../actor-follow'
6import { ActorImageModel } from '../../actor-image'
7
1export class ActorFollowTableAttributes { 8export class ActorFollowTableAttributes {
2 9
10 @Memoize()
3 getFollowAttributes () { 11 getFollowAttributes () {
4 return [ 12 logger.error('coucou')
5 '"ActorFollowModel"."id"', 13
6 '"ActorFollowModel"."state"', 14 return ActorFollowModel.getSQLAttributes('ActorFollowModel').join(', ')
7 '"ActorFollowModel"."score"',
8 '"ActorFollowModel"."url"',
9 '"ActorFollowModel"."actorId"',
10 '"ActorFollowModel"."targetActorId"',
11 '"ActorFollowModel"."createdAt"',
12 '"ActorFollowModel"."updatedAt"'
13 ].join(', ')
14 } 15 }
15 16
17 @Memoize()
16 getActorAttributes (actorTableName: string) { 18 getActorAttributes (actorTableName: string) {
17 return [ 19 return ActorModel.getSQLAttributes(actorTableName, `${actorTableName}.`).join(', ')
18 `"${actorTableName}"."id" AS "${actorTableName}.id"`,
19 `"${actorTableName}"."type" AS "${actorTableName}.type"`,
20 `"${actorTableName}"."preferredUsername" AS "${actorTableName}.preferredUsername"`,
21 `"${actorTableName}"."url" AS "${actorTableName}.url"`,
22 `"${actorTableName}"."publicKey" AS "${actorTableName}.publicKey"`,
23 `"${actorTableName}"."privateKey" AS "${actorTableName}.privateKey"`,
24 `"${actorTableName}"."followersCount" AS "${actorTableName}.followersCount"`,
25 `"${actorTableName}"."followingCount" AS "${actorTableName}.followingCount"`,
26 `"${actorTableName}"."inboxUrl" AS "${actorTableName}.inboxUrl"`,
27 `"${actorTableName}"."outboxUrl" AS "${actorTableName}.outboxUrl"`,
28 `"${actorTableName}"."sharedInboxUrl" AS "${actorTableName}.sharedInboxUrl"`,
29 `"${actorTableName}"."followersUrl" AS "${actorTableName}.followersUrl"`,
30 `"${actorTableName}"."followingUrl" AS "${actorTableName}.followingUrl"`,
31 `"${actorTableName}"."remoteCreatedAt" AS "${actorTableName}.remoteCreatedAt"`,
32 `"${actorTableName}"."serverId" AS "${actorTableName}.serverId"`,
33 `"${actorTableName}"."createdAt" AS "${actorTableName}.createdAt"`,
34 `"${actorTableName}"."updatedAt" AS "${actorTableName}.updatedAt"`
35 ].join(', ')
36 } 20 }
37 21
22 @Memoize()
38 getServerAttributes (actorTableName: string) { 23 getServerAttributes (actorTableName: string) {
39 return [ 24 return ServerModel.getSQLAttributes(`${actorTableName}->Server`, `${actorTableName}.Server.`).join(', ')
40 `"${actorTableName}->Server"."id" AS "${actorTableName}.Server.id"`,
41 `"${actorTableName}->Server"."host" AS "${actorTableName}.Server.host"`,
42 `"${actorTableName}->Server"."redundancyAllowed" AS "${actorTableName}.Server.redundancyAllowed"`,
43 `"${actorTableName}->Server"."createdAt" AS "${actorTableName}.Server.createdAt"`,
44 `"${actorTableName}->Server"."updatedAt" AS "${actorTableName}.Server.updatedAt"`
45 ].join(', ')
46 } 25 }
47 26
27 @Memoize()
48 getAvatarAttributes (actorTableName: string) { 28 getAvatarAttributes (actorTableName: string) {
49 return [ 29 return ActorImageModel.getSQLAttributes(`${actorTableName}->Avatars`, `${actorTableName}.Avatars.`).join(', ')
50 `"${actorTableName}->Avatars"."id" AS "${actorTableName}.Avatars.id"`,
51 `"${actorTableName}->Avatars"."filename" AS "${actorTableName}.Avatars.filename"`,
52 `"${actorTableName}->Avatars"."height" AS "${actorTableName}.Avatars.height"`,
53 `"${actorTableName}->Avatars"."width" AS "${actorTableName}.Avatars.width"`,
54 `"${actorTableName}->Avatars"."fileUrl" AS "${actorTableName}.Avatars.fileUrl"`,
55 `"${actorTableName}->Avatars"."onDisk" AS "${actorTableName}.Avatars.onDisk"`,
56 `"${actorTableName}->Avatars"."type" AS "${actorTableName}.Avatars.type"`,
57 `"${actorTableName}->Avatars"."actorId" AS "${actorTableName}.Avatars.actorId"`,
58 `"${actorTableName}->Avatars"."createdAt" AS "${actorTableName}.Avatars.createdAt"`,
59 `"${actorTableName}->Avatars"."updatedAt" AS "${actorTableName}.Avatars.updatedAt"`
60 ].join(', ')
61 } 30 }
62} 31}
diff --git a/server/models/actor/sql/shared/instance-list-follows-query-builder.ts b/server/models/actor/sql/shared/instance-list-follows-query-builder.ts
index 1d70fbe70..d9593e48b 100644
--- a/server/models/actor/sql/shared/instance-list-follows-query-builder.ts
+++ b/server/models/actor/sql/shared/instance-list-follows-query-builder.ts
@@ -1,7 +1,7 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import { AbstractRunQuery } from '@server/models/shared' 2import { AbstractRunQuery } from '@server/models/shared'
3import { getInstanceFollowsSort } from '@server/models/utils'
4import { ActorImageType } from '@shared/models' 3import { ActorImageType } from '@shared/models'
4import { getInstanceFollowsSort } from '../../../shared'
5import { ActorFollowTableAttributes } from './actor-follow-table-attributes' 5import { ActorFollowTableAttributes } from './actor-follow-table-attributes'
6 6
7type BaseOptions = { 7type BaseOptions = {
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
index 15909d5f3..c2a72b71f 100644
--- a/server/models/redundancy/video-redundancy.ts
+++ b/server/models/redundancy/video-redundancy.ts
@@ -34,7 +34,7 @@ import { CONFIG } from '../../initializers/config'
34import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants' 34import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants'
35import { ActorModel } from '../actor/actor' 35import { ActorModel } from '../actor/actor'
36import { ServerModel } from '../server/server' 36import { ServerModel } from '../server/server'
37import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils' 37import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../shared'
38import { ScheduleVideoUpdateModel } from '../video/schedule-video-update' 38import { ScheduleVideoUpdateModel } from '../video/schedule-video-update'
39import { VideoModel } from '../video/video' 39import { VideoModel } from '../video/video'
40import { VideoChannelModel } from '../video/video-channel' 40import { VideoChannelModel } from '../video/video-channel'
diff --git a/server/models/server/plugin.ts b/server/models/server/plugin.ts
index 71c205ffa..9948c9f7a 100644
--- a/server/models/server/plugin.ts
+++ b/server/models/server/plugin.ts
@@ -11,7 +11,7 @@ import {
11 isPluginStableVersionValid, 11 isPluginStableVersionValid,
12 isPluginTypeValid 12 isPluginTypeValid
13} from '../../helpers/custom-validators/plugins' 13} from '../../helpers/custom-validators/plugins'
14import { getSort, throwIfNotValid } from '../utils' 14import { getSort, throwIfNotValid } from '../shared'
15 15
16@DefaultScope(() => ({ 16@DefaultScope(() => ({
17 attributes: { 17 attributes: {
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts
index 9752dfbc3..3d755fe4a 100644
--- a/server/models/server/server-blocklist.ts
+++ b/server/models/server/server-blocklist.ts
@@ -4,7 +4,7 @@ import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormat
4import { ServerBlock } from '@shared/models' 4import { ServerBlock } from '@shared/models'
5import { AttributesOnly } from '@shared/typescript-utils' 5import { AttributesOnly } from '@shared/typescript-utils'
6import { AccountModel } from '../account/account' 6import { AccountModel } from '../account/account'
7import { createSafeIn, getSort, searchAttribute } from '../utils' 7import { createSafeIn, getSort, searchAttribute } from '../shared'
8import { ServerModel } from './server' 8import { ServerModel } from './server'
9 9
10enum ScopeNames { 10enum ScopeNames {
diff --git a/server/models/server/server.ts b/server/models/server/server.ts
index ef42de090..a5e05f460 100644
--- a/server/models/server/server.ts
+++ b/server/models/server/server.ts
@@ -4,7 +4,7 @@ import { MServer, MServerFormattable } from '@server/types/models/server'
4import { AttributesOnly } from '@shared/typescript-utils' 4import { AttributesOnly } from '@shared/typescript-utils'
5import { isHostValid } from '../../helpers/custom-validators/servers' 5import { isHostValid } from '../../helpers/custom-validators/servers'
6import { ActorModel } from '../actor/actor' 6import { ActorModel } from '../actor/actor'
7import { throwIfNotValid } from '../utils' 7import { buildSQLAttributes, throwIfNotValid } from '../shared'
8import { ServerBlocklistModel } from './server-blocklist' 8import { ServerBlocklistModel } from './server-blocklist'
9 9
10@Table({ 10@Table({
@@ -52,6 +52,18 @@ export class ServerModel extends Model<Partial<AttributesOnly<ServerModel>>> {
52 }) 52 })
53 BlockedBy: ServerBlocklistModel[] 53 BlockedBy: ServerBlocklistModel[]
54 54
55 // ---------------------------------------------------------------------------
56
57 static getSQLAttributes (tableName: string, aliasPrefix = '') {
58 return buildSQLAttributes({
59 model: this,
60 tableName,
61 aliasPrefix
62 })
63 }
64
65 // ---------------------------------------------------------------------------
66
55 static load (id: number, transaction?: Transaction): Promise<MServer> { 67 static load (id: number, transaction?: Transaction): Promise<MServer> {
56 const query = { 68 const query = {
57 where: { 69 where: {
diff --git a/server/models/shared/index.ts b/server/models/shared/index.ts
index 04528929c..5a7621e4d 100644
--- a/server/models/shared/index.ts
+++ b/server/models/shared/index.ts
@@ -1,4 +1,8 @@
1export * from './abstract-run-query' 1export * from './abstract-run-query'
2export * from './model-builder' 2export * from './model-builder'
3export * from './model-cache'
3export * from './query' 4export * from './query'
5export * from './sequelize-helpers'
6export * from './sort'
7export * from './sql'
4export * from './update' 8export * from './update'
diff --git a/server/models/shared/model-builder.ts b/server/models/shared/model-builder.ts
index c015ca4f5..07f7c4038 100644
--- a/server/models/shared/model-builder.ts
+++ b/server/models/shared/model-builder.ts
@@ -1,7 +1,24 @@
1import { isPlainObject } from 'lodash' 1import { isPlainObject } from 'lodash'
2import { Model as SequelizeModel, Sequelize } from 'sequelize' 2import { Model as SequelizeModel, ModelStatic, Sequelize } from 'sequelize'
3import { logger } from '@server/helpers/logger' 3import { logger } from '@server/helpers/logger'
4 4
5/**
6 *
7 * Build Sequelize models from sequelize raw query (that must use { nest: true } options)
8 *
9 * In order to sequelize to correctly build the JSON this class will ingest,
10 * the columns selected in the raw query should be in the following form:
11 * * All tables must be Pascal Cased (for example "VideoChannel")
12 * * Root table must end with `Model` (for example "VideoCommentModel")
13 * * Joined tables must contain the origin table name + '->JoinedTable'. For example:
14 * * "Actor" is joined to "Account": "Actor" table must be renamed "Account->Actor"
15 * * "Account->Actor" is joined to "Server": "Server" table must be renamed to "Account->Actor->Server"
16 * * Selected columns must be renamed to contain the JSON path:
17 * * "videoComment"."id": "VideoCommentModel"."id"
18 * * "Account"."Actor"."Server"."id": "Account.Actor.Server.id"
19 * * All tables must contain the row id
20 */
21
5export class ModelBuilder <T extends SequelizeModel> { 22export class ModelBuilder <T extends SequelizeModel> {
6 private readonly modelRegistry = new Map<string, T>() 23 private readonly modelRegistry = new Map<string, T>()
7 24
@@ -72,18 +89,18 @@ export class ModelBuilder <T extends SequelizeModel> {
72 'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName), 89 'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName),
73 { existing: this.sequelize.modelManager.all.map(m => m.name) } 90 { existing: this.sequelize.modelManager.all.map(m => m.name) }
74 ) 91 )
75 return undefined 92 return { created: false, model: null }
76 } 93 }
77 94
78 // FIXME: typings 95 const model = Model.build(json, { raw: true, isNewRecord: false })
79 const model = new (Model as any)(json) 96
80 this.modelRegistry.set(registryKey, model) 97 this.modelRegistry.set(registryKey, model)
81 98
82 return { created: true, model } 99 return { created: true, model }
83 } 100 }
84 101
85 private findModelBuilder (modelName: string) { 102 private findModelBuilder (modelName: string) {
86 return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName)) 103 return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName)) as ModelStatic<T>
87 } 104 }
88 105
89 private buildSequelizeModelName (modelName: string) { 106 private buildSequelizeModelName (modelName: string) {
diff --git a/server/models/model-cache.ts b/server/models/shared/model-cache.ts
index 3651267e7..3651267e7 100644
--- a/server/models/model-cache.ts
+++ b/server/models/shared/model-cache.ts
diff --git a/server/models/shared/query.ts b/server/models/shared/query.ts
index 036cc13c6..934acc21f 100644
--- a/server/models/shared/query.ts
+++ b/server/models/shared/query.ts
@@ -1,17 +1,82 @@
1import { BindOrReplacements, QueryTypes } from 'sequelize' 1import { BindOrReplacements, Op, QueryTypes, Sequelize } from 'sequelize'
2import { sequelizeTypescript } from '@server/initializers/database' 2import validator from 'validator'
3import { forceNumber } from '@shared/core-utils'
3 4
4function doesExist (query: string, bind?: BindOrReplacements) { 5function doesExist (sequelize: Sequelize, query: string, bind?: BindOrReplacements) {
5 const options = { 6 const options = {
6 type: QueryTypes.SELECT as QueryTypes.SELECT, 7 type: QueryTypes.SELECT as QueryTypes.SELECT,
7 bind, 8 bind,
8 raw: true 9 raw: true
9 } 10 }
10 11
11 return sequelizeTypescript.query(query, options) 12 return sequelize.query(query, options)
12 .then(results => results.length === 1) 13 .then(results => results.length === 1)
13} 14}
14 15
16function createSimilarityAttribute (col: string, value: string) {
17 return Sequelize.fn(
18 'similarity',
19
20 searchTrigramNormalizeCol(col),
21
22 searchTrigramNormalizeValue(value)
23 )
24}
25
26function buildWhereIdOrUUID (id: number | string) {
27 return validator.isInt('' + id) ? { id } : { uuid: id }
28}
29
30function parseAggregateResult (result: any) {
31 if (!result) return 0
32
33 const total = forceNumber(result)
34 if (isNaN(total)) return 0
35
36 return total
37}
38
39function parseRowCountResult (result: any) {
40 if (result.length !== 0) return result[0].total
41
42 return 0
43}
44
45function createSafeIn (sequelize: Sequelize, toEscape: (string | number)[], additionalUnescaped: string[] = []) {
46 return toEscape.map(t => {
47 return t === null
48 ? null
49 : sequelize.escape('' + t)
50 }).concat(additionalUnescaped).join(', ')
51}
52
53function searchAttribute (sourceField?: string, targetField?: string) {
54 if (!sourceField) return {}
55
56 return {
57 [targetField]: {
58 // FIXME: ts error
59 [Op.iLike as any]: `%${sourceField}%`
60 }
61 }
62}
63
15export { 64export {
16 doesExist 65 doesExist,
66 createSimilarityAttribute,
67 buildWhereIdOrUUID,
68 parseAggregateResult,
69 parseRowCountResult,
70 createSafeIn,
71 searchAttribute
72}
73
74// ---------------------------------------------------------------------------
75
76function searchTrigramNormalizeValue (value: string) {
77 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', value))
78}
79
80function searchTrigramNormalizeCol (col: string) {
81 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
17} 82}
diff --git a/server/models/shared/sequelize-helpers.ts b/server/models/shared/sequelize-helpers.ts
new file mode 100644
index 000000000..7af8471dc
--- /dev/null
+++ b/server/models/shared/sequelize-helpers.ts
@@ -0,0 +1,39 @@
1import { Sequelize } from 'sequelize'
2
3function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
4 if (!model.createdAt || !model.updatedAt) {
5 throw new Error('Miss createdAt & updatedAt attributes to model')
6 }
7
8 const now = Date.now()
9 const createdAtTime = model.createdAt.getTime()
10 const updatedAtTime = model.updatedAt.getTime()
11
12 return (now - createdAtTime) > refreshInterval && (now - updatedAtTime) > refreshInterval
13}
14
15function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value', nullable = false) {
16 if (nullable && (value === null || value === undefined)) return
17
18 if (validator(value) === false) {
19 throw new Error(`"${value}" is not a valid ${fieldName}.`)
20 }
21}
22
23function buildTrigramSearchIndex (indexName: string, attribute: string) {
24 return {
25 name: indexName,
26 // FIXME: gin_trgm_ops is not taken into account in Sequelize 6, so adding it ourselves in the literal function
27 fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + ')) gin_trgm_ops') as any ],
28 using: 'gin',
29 operator: 'gin_trgm_ops'
30 }
31}
32
33// ---------------------------------------------------------------------------
34
35export {
36 throwIfNotValid,
37 buildTrigramSearchIndex,
38 isOutdated
39}
diff --git a/server/models/shared/sort.ts b/server/models/shared/sort.ts
new file mode 100644
index 000000000..77e84dcf4
--- /dev/null
+++ b/server/models/shared/sort.ts
@@ -0,0 +1,160 @@
1import { literal, OrderItem, Sequelize } from 'sequelize'
2
3// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
4function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
5 const { direction, field } = buildSortDirectionAndField(value)
6
7 let finalField: string | ReturnType<typeof Sequelize.col>
8
9 if (field.toLowerCase() === 'match') { // Search
10 finalField = Sequelize.col('similarity')
11 } else {
12 finalField = field
13 }
14
15 return [ [ finalField, direction ], lastSort ]
16}
17
18function getAdminUsersSort (value: string): OrderItem[] {
19 const { direction, field } = buildSortDirectionAndField(value)
20
21 let finalField: string | ReturnType<typeof Sequelize.col>
22
23 if (field === 'videoQuotaUsed') { // Users list
24 finalField = Sequelize.col('videoQuotaUsed')
25 } else {
26 finalField = field
27 }
28
29 const nullPolicy = direction === 'ASC'
30 ? 'NULLS FIRST'
31 : 'NULLS LAST'
32
33 // FIXME: typings
34 return [ [ finalField as any, direction, nullPolicy ], [ 'id', 'ASC' ] ]
35}
36
37function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
38 const { direction, field } = buildSortDirectionAndField(value)
39
40 if (field.toLowerCase() === 'name') {
41 return [ [ 'displayName', direction ], lastSort ]
42 }
43
44 return getSort(value, lastSort)
45}
46
47function getCommentSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
48 const { direction, field } = buildSortDirectionAndField(value)
49
50 if (field === 'totalReplies') {
51 return [
52 [ Sequelize.literal('"totalReplies"'), direction ],
53 lastSort
54 ]
55 }
56
57 return getSort(value, lastSort)
58}
59
60function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
61 const { direction, field } = buildSortDirectionAndField(value)
62
63 if (field.toLowerCase() === 'trending') { // Sort by aggregation
64 return [
65 [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ],
66
67 [ Sequelize.col('VideoModel.views'), direction ],
68
69 lastSort
70 ]
71 } else if (field === 'publishedAt') {
72 return [
73 [ 'ScheduleVideoUpdate', 'updateAt', direction + ' NULLS LAST' ],
74
75 [ Sequelize.col('VideoModel.publishedAt'), direction ],
76
77 lastSort
78 ]
79 }
80
81 let finalField: string | ReturnType<typeof Sequelize.col>
82
83 // Alias
84 if (field.toLowerCase() === 'match') { // Search
85 finalField = Sequelize.col('similarity')
86 } else {
87 finalField = field
88 }
89
90 const firstSort: OrderItem = typeof finalField === 'string'
91 ? finalField.split('.').concat([ direction ]) as OrderItem
92 : [ finalField, direction ]
93
94 return [ firstSort, lastSort ]
95}
96
97function getBlacklistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
98 const { direction, field } = buildSortDirectionAndField(value)
99
100 const videoFields = new Set([ 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid' ])
101
102 if (videoFields.has(field)) {
103 return [
104 [ literal(`"Video.${field}" ${direction}`) ],
105 lastSort
106 ] as OrderItem[]
107 }
108
109 return getSort(value, lastSort)
110}
111
112function getInstanceFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
113 const { direction, field } = buildSortDirectionAndField(value)
114
115 if (field === 'redundancyAllowed') {
116 return [
117 [ 'ActorFollowing.Server.redundancyAllowed', direction ],
118 lastSort
119 ]
120 }
121
122 return getSort(value, lastSort)
123}
124
125function getChannelSyncSort (value: string): OrderItem[] {
126 const { direction, field } = buildSortDirectionAndField(value)
127 if (field.toLowerCase() === 'videochannel') {
128 return [
129 [ literal('"VideoChannel.name"'), direction ]
130 ]
131 }
132 return [ [ field, direction ] ]
133}
134
135function buildSortDirectionAndField (value: string) {
136 let field: string
137 let direction: 'ASC' | 'DESC'
138
139 if (value.substring(0, 1) === '-') {
140 direction = 'DESC'
141 field = value.substring(1)
142 } else {
143 direction = 'ASC'
144 field = value
145 }
146
147 return { direction, field }
148}
149
150export {
151 buildSortDirectionAndField,
152 getPlaylistSort,
153 getSort,
154 getCommentSort,
155 getAdminUsersSort,
156 getVideoSort,
157 getBlacklistSort,
158 getChannelSyncSort,
159 getInstanceFollowsSort
160}
diff --git a/server/models/shared/sql.ts b/server/models/shared/sql.ts
new file mode 100644
index 000000000..5aaeb49f0
--- /dev/null
+++ b/server/models/shared/sql.ts
@@ -0,0 +1,68 @@
1import { literal, Model, ModelStatic } from 'sequelize'
2import { forceNumber } from '@shared/core-utils'
3import { AttributesOnly } from '@shared/typescript-utils'
4
5function buildLocalAccountIdsIn () {
6 return literal(
7 '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)'
8 )
9}
10
11function buildLocalActorIdsIn () {
12 return literal(
13 '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)'
14 )
15}
16
17function buildBlockedAccountSQL (blockerIds: number[]) {
18 const blockerIdsString = blockerIds.join(', ')
19
20 return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
21 ' UNION ' +
22 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
23 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
24 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
25}
26
27function buildServerIdsFollowedBy (actorId: any) {
28 const actorIdNumber = forceNumber(actorId)
29
30 return '(' +
31 'SELECT "actor"."serverId" FROM "actorFollow" ' +
32 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
33 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
34 ')'
35}
36
37function buildSQLAttributes<M extends Model> (options: {
38 model: ModelStatic<M>
39 tableName: string
40
41 excludeAttributes?: Exclude<keyof AttributesOnly<M>, symbol>[]
42 aliasPrefix?: string
43}) {
44 const { model, tableName, aliasPrefix, excludeAttributes } = options
45
46 const attributes = Object.keys(model.getAttributes()) as Exclude<keyof AttributesOnly<M>, symbol>[]
47
48 return attributes
49 .filter(a => {
50 if (!excludeAttributes) return true
51 if (excludeAttributes.includes(a)) return false
52
53 return true
54 })
55 .map(a => {
56 return `"${tableName}"."${a}" AS "${aliasPrefix || ''}${a}"`
57 })
58}
59
60// ---------------------------------------------------------------------------
61
62export {
63 buildSQLAttributes,
64 buildBlockedAccountSQL,
65 buildServerIdsFollowedBy,
66 buildLocalAccountIdsIn,
67 buildLocalActorIdsIn
68}
diff --git a/server/models/shared/update.ts b/server/models/shared/update.ts
index d338211e3..d02c4535d 100644
--- a/server/models/shared/update.ts
+++ b/server/models/shared/update.ts
@@ -1,9 +1,15 @@
1import { QueryTypes, Transaction } from 'sequelize' 1import { QueryTypes, Sequelize, Transaction } from 'sequelize'
2import { sequelizeTypescript } from '@server/initializers/database'
3 2
4// Sequelize always skip the update if we only update updatedAt field 3// Sequelize always skip the update if we only update updatedAt field
5function setAsUpdated (table: string, id: number, transaction?: Transaction) { 4function setAsUpdated (options: {
6 return sequelizeTypescript.query( 5 sequelize: Sequelize
6 table: string
7 id: number
8 transaction?: Transaction
9}) {
10 const { sequelize, table, id, transaction } = options
11
12 return sequelize.query(
7 `UPDATE "${table}" SET "updatedAt" = :updatedAt WHERE id = :id`, 13 `UPDATE "${table}" SET "updatedAt" = :updatedAt WHERE id = :id`,
8 { 14 {
9 replacements: { table, id, updatedAt: new Date() }, 15 replacements: { table, id, updatedAt: new Date() },
diff --git a/server/models/user/sql/user-notitication-list-query-builder.ts b/server/models/user/sql/user-notitication-list-query-builder.ts
index 31b4932bf..d11546df0 100644
--- a/server/models/user/sql/user-notitication-list-query-builder.ts
+++ b/server/models/user/sql/user-notitication-list-query-builder.ts
@@ -1,8 +1,8 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import { AbstractRunQuery, ModelBuilder } from '@server/models/shared' 2import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
3import { getSort } from '@server/models/utils'
4import { UserNotificationModelForApi } from '@server/types/models' 3import { UserNotificationModelForApi } from '@server/types/models'
5import { ActorImageType } from '@shared/models' 4import { ActorImageType } from '@shared/models'
5import { getSort } from '../../shared'
6 6
7export interface ListNotificationsOptions { 7export interface ListNotificationsOptions {
8 userId: number 8 userId: number
diff --git a/server/models/user/user-notification-setting.ts b/server/models/user/user-notification-setting.ts
index 66e1d85b3..394494c0c 100644
--- a/server/models/user/user-notification-setting.ts
+++ b/server/models/user/user-notification-setting.ts
@@ -17,7 +17,7 @@ import { MNotificationSettingFormattable } from '@server/types/models'
17import { AttributesOnly } from '@shared/typescript-utils' 17import { AttributesOnly } from '@shared/typescript-utils'
18import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model' 18import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
19import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications' 19import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
20import { throwIfNotValid } from '../utils' 20import { throwIfNotValid } from '../shared'
21import { UserModel } from './user' 21import { UserModel } from './user'
22 22
23@Table({ 23@Table({
diff --git a/server/models/user/user-notification.ts b/server/models/user/user-notification.ts
index d37fa5dc7..6e134158f 100644
--- a/server/models/user/user-notification.ts
+++ b/server/models/user/user-notification.ts
@@ -13,7 +13,7 @@ import { AccountModel } from '../account/account'
13import { ActorFollowModel } from '../actor/actor-follow' 13import { ActorFollowModel } from '../actor/actor-follow'
14import { ApplicationModel } from '../application/application' 14import { ApplicationModel } from '../application/application'
15import { PluginModel } from '../server/plugin' 15import { PluginModel } from '../server/plugin'
16import { throwIfNotValid } from '../utils' 16import { throwIfNotValid } from '../shared'
17import { VideoModel } from '../video/video' 17import { VideoModel } from '../video/video'
18import { VideoBlacklistModel } from '../video/video-blacklist' 18import { VideoBlacklistModel } from '../video/video-blacklist'
19import { VideoCommentModel } from '../video/video-comment' 19import { VideoCommentModel } from '../video/video-comment'
diff --git a/server/models/user/user.ts b/server/models/user/user.ts
index 672728a2a..0932a367a 100644
--- a/server/models/user/user.ts
+++ b/server/models/user/user.ts
@@ -30,6 +30,7 @@ import {
30 MUserNotifSettingChannelDefault, 30 MUserNotifSettingChannelDefault,
31 MUserWithNotificationSetting 31 MUserWithNotificationSetting
32} from '@server/types/models' 32} from '@server/types/models'
33import { forceNumber } from '@shared/core-utils'
33import { AttributesOnly } from '@shared/typescript-utils' 34import { AttributesOnly } from '@shared/typescript-utils'
34import { hasUserRight, USER_ROLE_LABELS } from '../../../shared/core-utils/users' 35import { hasUserRight, USER_ROLE_LABELS } from '../../../shared/core-utils/users'
35import { AbuseState, MyUser, UserRight, VideoPlaylistType } from '../../../shared/models' 36import { AbuseState, MyUser, UserRight, VideoPlaylistType } from '../../../shared/models'
@@ -63,14 +64,13 @@ import { ActorModel } from '../actor/actor'
63import { ActorFollowModel } from '../actor/actor-follow' 64import { ActorFollowModel } from '../actor/actor-follow'
64import { ActorImageModel } from '../actor/actor-image' 65import { ActorImageModel } from '../actor/actor-image'
65import { OAuthTokenModel } from '../oauth/oauth-token' 66import { OAuthTokenModel } from '../oauth/oauth-token'
66import { getAdminUsersSort, throwIfNotValid } from '../utils' 67import { getAdminUsersSort, throwIfNotValid } from '../shared'
67import { VideoModel } from '../video/video' 68import { VideoModel } from '../video/video'
68import { VideoChannelModel } from '../video/video-channel' 69import { VideoChannelModel } from '../video/video-channel'
69import { VideoImportModel } from '../video/video-import' 70import { VideoImportModel } from '../video/video-import'
70import { VideoLiveModel } from '../video/video-live' 71import { VideoLiveModel } from '../video/video-live'
71import { VideoPlaylistModel } from '../video/video-playlist' 72import { VideoPlaylistModel } from '../video/video-playlist'
72import { UserNotificationSettingModel } from './user-notification-setting' 73import { UserNotificationSettingModel } from './user-notification-setting'
73import { forceNumber } from '@shared/core-utils'
74 74
75enum ScopeNames { 75enum ScopeNames {
76 FOR_ME_API = 'FOR_ME_API', 76 FOR_ME_API = 'FOR_ME_API',
diff --git a/server/models/utils.ts b/server/models/utils.ts
deleted file mode 100644
index 3476799ce..000000000
--- a/server/models/utils.ts
+++ /dev/null
@@ -1,317 +0,0 @@
1import { literal, Op, OrderItem, Sequelize } from 'sequelize'
2import validator from 'validator'
3import { forceNumber } from '@shared/core-utils'
4
5type SortType = { sortModel: string, sortValue: string }
6
7// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
8function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
9 const { direction, field } = buildDirectionAndField(value)
10
11 let finalField: string | ReturnType<typeof Sequelize.col>
12
13 if (field.toLowerCase() === 'match') { // Search
14 finalField = Sequelize.col('similarity')
15 } else {
16 finalField = field
17 }
18
19 return [ [ finalField, direction ], lastSort ]
20}
21
22function getAdminUsersSort (value: string): OrderItem[] {
23 const { direction, field } = buildDirectionAndField(value)
24
25 let finalField: string | ReturnType<typeof Sequelize.col>
26
27 if (field === 'videoQuotaUsed') { // Users list
28 finalField = Sequelize.col('videoQuotaUsed')
29 } else {
30 finalField = field
31 }
32
33 const nullPolicy = direction === 'ASC'
34 ? 'NULLS FIRST'
35 : 'NULLS LAST'
36
37 // FIXME: typings
38 return [ [ finalField as any, direction, nullPolicy ], [ 'id', 'ASC' ] ]
39}
40
41function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
42 const { direction, field } = buildDirectionAndField(value)
43
44 if (field.toLowerCase() === 'name') {
45 return [ [ 'displayName', direction ], lastSort ]
46 }
47
48 return getSort(value, lastSort)
49}
50
51function getCommentSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
52 const { direction, field } = buildDirectionAndField(value)
53
54 if (field === 'totalReplies') {
55 return [
56 [ Sequelize.literal('"totalReplies"'), direction ],
57 lastSort
58 ]
59 }
60
61 return getSort(value, lastSort)
62}
63
64function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
65 const { direction, field } = buildDirectionAndField(value)
66
67 if (field.toLowerCase() === 'trending') { // Sort by aggregation
68 return [
69 [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ],
70
71 [ Sequelize.col('VideoModel.views'), direction ],
72
73 lastSort
74 ]
75 } else if (field === 'publishedAt') {
76 return [
77 [ 'ScheduleVideoUpdate', 'updateAt', direction + ' NULLS LAST' ],
78
79 [ Sequelize.col('VideoModel.publishedAt'), direction ],
80
81 lastSort
82 ]
83 }
84
85 let finalField: string | ReturnType<typeof Sequelize.col>
86
87 // Alias
88 if (field.toLowerCase() === 'match') { // Search
89 finalField = Sequelize.col('similarity')
90 } else {
91 finalField = field
92 }
93
94 const firstSort: OrderItem = typeof finalField === 'string'
95 ? finalField.split('.').concat([ direction ]) as OrderItem
96 : [ finalField, direction ]
97
98 return [ firstSort, lastSort ]
99}
100
101function getBlacklistSort (model: any, value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
102 const [ firstSort ] = getSort(value)
103
104 if (model) return [ [ literal(`"${model}.${firstSort[0]}" ${firstSort[1]}`) ], lastSort ] as OrderItem[]
105 return [ firstSort, lastSort ]
106}
107
108function getInstanceFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
109 const { direction, field } = buildDirectionAndField(value)
110
111 if (field === 'redundancyAllowed') {
112 return [
113 [ 'ActorFollowing.Server.redundancyAllowed', direction ],
114 lastSort
115 ]
116 }
117
118 return getSort(value, lastSort)
119}
120
121function getChannelSyncSort (value: string): OrderItem[] {
122 const { direction, field } = buildDirectionAndField(value)
123 if (field.toLowerCase() === 'videochannel') {
124 return [
125 [ literal('"VideoChannel.name"'), direction ]
126 ]
127 }
128 return [ [ field, direction ] ]
129}
130
131function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
132 if (!model.createdAt || !model.updatedAt) {
133 throw new Error('Miss createdAt & updatedAt attributes to model')
134 }
135
136 const now = Date.now()
137 const createdAtTime = model.createdAt.getTime()
138 const updatedAtTime = model.updatedAt.getTime()
139
140 return (now - createdAtTime) > refreshInterval && (now - updatedAtTime) > refreshInterval
141}
142
143function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value', nullable = false) {
144 if (nullable && (value === null || value === undefined)) return
145
146 if (validator(value) === false) {
147 throw new Error(`"${value}" is not a valid ${fieldName}.`)
148 }
149}
150
151function buildTrigramSearchIndex (indexName: string, attribute: string) {
152 return {
153 name: indexName,
154 // FIXME: gin_trgm_ops is not taken into account in Sequelize 6, so adding it ourselves in the literal function
155 fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + ')) gin_trgm_ops') as any ],
156 using: 'gin',
157 operator: 'gin_trgm_ops'
158 }
159}
160
161function createSimilarityAttribute (col: string, value: string) {
162 return Sequelize.fn(
163 'similarity',
164
165 searchTrigramNormalizeCol(col),
166
167 searchTrigramNormalizeValue(value)
168 )
169}
170
171function buildBlockedAccountSQL (blockerIds: number[]) {
172 const blockerIdsString = blockerIds.join(', ')
173
174 return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
175 ' UNION ' +
176 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
177 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
178 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
179}
180
181function buildBlockedAccountSQLOptimized (columnNameJoin: string, blockerIds: number[]) {
182 const blockerIdsString = blockerIds.join(', ')
183
184 return [
185 literal(
186 `NOT EXISTS (` +
187 ` SELECT 1 FROM "accountBlocklist" ` +
188 ` WHERE "targetAccountId" = ${columnNameJoin} ` +
189 ` AND "accountId" IN (${blockerIdsString})` +
190 `)`
191 ),
192
193 literal(
194 `NOT EXISTS (` +
195 ` SELECT 1 FROM "account" ` +
196 ` INNER JOIN "actor" ON account."actorId" = actor.id ` +
197 ` INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
198 ` WHERE "account"."id" = ${columnNameJoin} ` +
199 ` AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
200 `)`
201 )
202 ]
203}
204
205function buildServerIdsFollowedBy (actorId: any) {
206 const actorIdNumber = forceNumber(actorId)
207
208 return '(' +
209 'SELECT "actor"."serverId" FROM "actorFollow" ' +
210 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
211 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
212 ')'
213}
214
215function buildWhereIdOrUUID (id: number | string) {
216 return validator.isInt('' + id) ? { id } : { uuid: id }
217}
218
219function parseAggregateResult (result: any) {
220 if (!result) return 0
221
222 const total = forceNumber(result)
223 if (isNaN(total)) return 0
224
225 return total
226}
227
228function parseRowCountResult (result: any) {
229 if (result.length !== 0) return result[0].total
230
231 return 0
232}
233
234function createSafeIn (sequelize: Sequelize, stringArr: (string | number)[]) {
235 return stringArr.map(t => {
236 return t === null
237 ? null
238 : sequelize.escape('' + t)
239 }).join(', ')
240}
241
242function buildLocalAccountIdsIn () {
243 return literal(
244 '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)'
245 )
246}
247
248function buildLocalActorIdsIn () {
249 return literal(
250 '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)'
251 )
252}
253
254function buildDirectionAndField (value: string) {
255 let field: string
256 let direction: 'ASC' | 'DESC'
257
258 if (value.substring(0, 1) === '-') {
259 direction = 'DESC'
260 field = value.substring(1)
261 } else {
262 direction = 'ASC'
263 field = value
264 }
265
266 return { direction, field }
267}
268
269function searchAttribute (sourceField?: string, targetField?: string) {
270 if (!sourceField) return {}
271
272 return {
273 [targetField]: {
274 // FIXME: ts error
275 [Op.iLike as any]: `%${sourceField}%`
276 }
277 }
278}
279
280// ---------------------------------------------------------------------------
281
282export {
283 buildBlockedAccountSQL,
284 buildBlockedAccountSQLOptimized,
285 buildLocalActorIdsIn,
286 getPlaylistSort,
287 SortType,
288 buildLocalAccountIdsIn,
289 getSort,
290 getCommentSort,
291 getAdminUsersSort,
292 getVideoSort,
293 getBlacklistSort,
294 getChannelSyncSort,
295 createSimilarityAttribute,
296 throwIfNotValid,
297 buildServerIdsFollowedBy,
298 buildTrigramSearchIndex,
299 buildWhereIdOrUUID,
300 isOutdated,
301 parseAggregateResult,
302 getInstanceFollowsSort,
303 buildDirectionAndField,
304 createSafeIn,
305 searchAttribute,
306 parseRowCountResult
307}
308
309// ---------------------------------------------------------------------------
310
311function searchTrigramNormalizeValue (value: string) {
312 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', value))
313}
314
315function searchTrigramNormalizeCol (col: string) {
316 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
317}
diff --git a/server/models/video/sql/comment/video-comment-list-query-builder.ts b/server/models/video/sql/comment/video-comment-list-query-builder.ts
new file mode 100644
index 000000000..3960f6b13
--- /dev/null
+++ b/server/models/video/sql/comment/video-comment-list-query-builder.ts
@@ -0,0 +1,385 @@
1import { Model, Sequelize, Transaction } from 'sequelize'
2import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
3import { ActorImageType, VideoPrivacy } from '@shared/models'
4import { createSafeIn, getCommentSort, parseRowCountResult } from '../../../shared'
5import { VideoCommentTableAttributes } from './video-comment-table-attributes'
6
7export interface ListVideoCommentsOptions {
8 selectType: 'api' | 'feed' | 'comment-only'
9
10 start?: number
11 count?: number
12 sort?: string
13
14 videoId?: number
15 threadId?: number
16 accountId?: number
17 videoChannelId?: number
18
19 blockerAccountIds?: number[]
20
21 isThread?: boolean
22 notDeleted?: boolean
23 isLocal?: boolean
24 onLocalVideo?: boolean
25 onPublicVideo?: boolean
26 videoAccountOwnerId?: boolean
27
28 search?: string
29 searchAccount?: string
30 searchVideo?: string
31
32 includeReplyCounters?: boolean
33
34 transaction?: Transaction
35}
36
37export class VideoCommentListQueryBuilder extends AbstractRunQuery {
38 private readonly tableAttributes = new VideoCommentTableAttributes()
39
40 private innerQuery: string
41
42 private select = ''
43 private joins = ''
44
45 private innerSelect = ''
46 private innerJoins = ''
47 private innerWhere = ''
48
49 private readonly built = {
50 cte: false,
51 accountJoin: false,
52 videoJoin: false,
53 videoChannelJoin: false,
54 avatarJoin: false
55 }
56
57 constructor (
58 protected readonly sequelize: Sequelize,
59 private readonly options: ListVideoCommentsOptions
60 ) {
61 super(sequelize)
62 }
63
64 async listComments <T extends Model> () {
65 this.buildListQuery()
66
67 const results = await this.runQuery({ nest: true, transaction: this.options.transaction })
68 const modelBuilder = new ModelBuilder<T>(this.sequelize)
69
70 return modelBuilder.createModels(results, 'VideoComment')
71 }
72
73 async countComments () {
74 this.buildCountQuery()
75
76 const result = await this.runQuery({ transaction: this.options.transaction })
77
78 return parseRowCountResult(result)
79 }
80
81 // ---------------------------------------------------------------------------
82
83 private buildListQuery () {
84 this.buildInnerListQuery()
85 this.buildListSelect()
86
87 this.query = `${this.select} ` +
88 `FROM (${this.innerQuery}) AS "VideoCommentModel" ` +
89 `${this.joins} ` +
90 `${this.getOrder()}`
91 }
92
93 private buildInnerListQuery () {
94 this.buildWhere()
95 this.buildInnerListSelect()
96
97 this.innerQuery = `${this.innerSelect} ` +
98 `FROM "videoComment" AS "VideoCommentModel" ` +
99 `${this.innerJoins} ` +
100 `${this.innerWhere} ` +
101 `${this.getOrder()} ` +
102 `${this.getInnerLimit()}`
103 }
104
105 // ---------------------------------------------------------------------------
106
107 private buildCountQuery () {
108 this.buildWhere()
109
110 this.query = `SELECT COUNT(*) AS "total" ` +
111 `FROM "videoComment" AS "VideoCommentModel" ` +
112 `${this.innerJoins} ` +
113 `${this.innerWhere}`
114 }
115
116 // ---------------------------------------------------------------------------
117
118 private buildWhere () {
119 let where: string[] = []
120
121 if (this.options.videoId) {
122 this.replacements.videoId = this.options.videoId
123
124 where.push('"VideoCommentModel"."videoId" = :videoId')
125 }
126
127 if (this.options.threadId) {
128 this.replacements.threadId = this.options.threadId
129
130 where.push('("VideoCommentModel"."id" = :threadId OR "VideoCommentModel"."originCommentId" = :threadId)')
131 }
132
133 if (this.options.accountId) {
134 this.replacements.accountId = this.options.accountId
135
136 where.push('"VideoCommentModel"."accountId" = :accountId')
137 }
138
139 if (this.options.videoChannelId) {
140 this.buildVideoChannelJoin()
141
142 this.replacements.videoChannelId = this.options.videoChannelId
143
144 where.push('"Account->VideoChannel"."id" = :videoChannelId')
145 }
146
147 if (this.options.blockerAccountIds) {
148 this.buildVideoChannelJoin()
149
150 where = where.concat(this.getBlockWhere('VideoCommentModel', 'Video->VideoChannel'))
151 }
152
153 if (this.options.isThread === true) {
154 where.push('"VideoCommentModel"."inReplyToCommentId" IS NULL')
155 }
156
157 if (this.options.notDeleted === true) {
158 where.push('"VideoCommentModel"."deletedAt" IS NULL')
159 }
160
161 if (this.options.isLocal === true) {
162 this.buildAccountJoin()
163
164 where.push('"Account->Actor"."serverId" IS NULL')
165 } else if (this.options.isLocal === false) {
166 this.buildAccountJoin()
167
168 where.push('"Account->Actor"."serverId" IS NOT NULL')
169 }
170
171 if (this.options.onLocalVideo === true) {
172 this.buildVideoJoin()
173
174 where.push('"Video"."remote" IS FALSE')
175 } else if (this.options.onLocalVideo === false) {
176 this.buildVideoJoin()
177
178 where.push('"Video"."remote" IS TRUE')
179 }
180
181 if (this.options.onPublicVideo === true) {
182 this.buildVideoJoin()
183
184 where.push(`"Video"."privacy" = ${VideoPrivacy.PUBLIC}`)
185 }
186
187 if (this.options.videoAccountOwnerId) {
188 this.buildVideoChannelJoin()
189
190 this.replacements.videoAccountOwnerId = this.options.videoAccountOwnerId
191
192 where.push(`"Video->VideoChannel"."accountId" = :videoAccountOwnerId`)
193 }
194
195 if (this.options.search) {
196 this.buildVideoJoin()
197 this.buildAccountJoin()
198
199 const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%')
200
201 where.push(
202 `(` +
203 `"VideoCommentModel"."text" ILIKE ${escapedLikeSearch} OR ` +
204 `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
205 `"Account"."name" ILIKE ${escapedLikeSearch} OR ` +
206 `"Video"."name" ILIKE ${escapedLikeSearch} ` +
207 `)`
208 )
209 }
210
211 if (this.options.searchAccount) {
212 this.buildAccountJoin()
213
214 const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchAccount + '%')
215
216 where.push(
217 `(` +
218 `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
219 `"Account"."name" ILIKE ${escapedLikeSearch} ` +
220 `)`
221 )
222 }
223
224 if (this.options.searchVideo) {
225 this.buildVideoJoin()
226
227 const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchVideo + '%')
228
229 where.push(`"Video"."name" ILIKE ${escapedLikeSearch}`)
230 }
231
232 if (where.length !== 0) {
233 this.innerWhere = `WHERE ${where.join(' AND ')}`
234 }
235 }
236
237 private buildAccountJoin () {
238 if (this.built.accountJoin) return
239
240 this.innerJoins += ' LEFT JOIN "account" "Account" ON "Account"."id" = "VideoCommentModel"."accountId" ' +
241 'LEFT JOIN "actor" "Account->Actor" ON "Account->Actor"."id" = "Account"."actorId" ' +
242 'LEFT JOIN "server" "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" '
243
244 this.built.accountJoin = true
245 }
246
247 private buildVideoJoin () {
248 if (this.built.videoJoin) return
249
250 this.innerJoins += ' LEFT JOIN "video" "Video" ON "Video"."id" = "VideoCommentModel"."videoId" '
251
252 this.built.videoJoin = true
253 }
254
255 private buildVideoChannelJoin () {
256 if (this.built.videoChannelJoin) return
257
258 this.buildVideoJoin()
259
260 this.innerJoins += ' LEFT JOIN "videoChannel" "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id" '
261
262 this.built.videoChannelJoin = true
263 }
264
265 private buildAvatarsJoin () {
266 if (this.options.selectType !== 'api' && this.options.selectType !== 'feed') return ''
267 if (this.built.avatarJoin) return
268
269 this.joins += `LEFT JOIN "actorImage" "Account->Actor->Avatars" ` +
270 `ON "VideoCommentModel"."Account.Actor.id" = "Account->Actor->Avatars"."actorId" ` +
271 `AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
272
273 this.built.avatarJoin = true
274 }
275
276 // ---------------------------------------------------------------------------
277
278 private buildListSelect () {
279 const toSelect = [ '"VideoCommentModel".*' ]
280
281 if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
282 this.buildAvatarsJoin()
283
284 toSelect.push(this.tableAttributes.getAvatarAttributes())
285 }
286
287 if (this.options.includeReplyCounters === true) {
288 toSelect.push(this.getTotalRepliesSelect())
289 toSelect.push(this.getAuthorTotalRepliesSelect())
290 }
291
292 this.select = this.buildSelect(toSelect)
293 }
294
295 private buildInnerListSelect () {
296 let toSelect = [ this.tableAttributes.getVideoCommentAttributes() ]
297
298 if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
299 this.buildAccountJoin()
300 this.buildVideoJoin()
301
302 toSelect = toSelect.concat([
303 this.tableAttributes.getVideoAttributes(),
304 this.tableAttributes.getAccountAttributes(),
305 this.tableAttributes.getActorAttributes(),
306 this.tableAttributes.getServerAttributes()
307 ])
308 }
309
310 this.innerSelect = this.buildSelect(toSelect)
311 }
312
313 // ---------------------------------------------------------------------------
314
315 private getBlockWhere (commentTableName: string, channelTableName: string) {
316 const where: string[] = []
317
318 const blockerIdsString = createSafeIn(
319 this.sequelize,
320 this.options.blockerAccountIds,
321 [ `"${channelTableName}"."accountId"` ]
322 )
323
324 where.push(
325 `NOT EXISTS (` +
326 `SELECT 1 FROM "accountBlocklist" ` +
327 `WHERE "targetAccountId" = "${commentTableName}"."accountId" ` +
328 `AND "accountId" IN (${blockerIdsString})` +
329 `)`
330 )
331
332 where.push(
333 `NOT EXISTS (` +
334 `SELECT 1 FROM "account" ` +
335 `INNER JOIN "actor" ON account."actorId" = actor.id ` +
336 `INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
337 `WHERE "account"."id" = "${commentTableName}"."accountId" ` +
338 `AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
339 `)`
340 )
341
342 return where
343 }
344
345 // ---------------------------------------------------------------------------
346
347 private getTotalRepliesSelect () {
348 const blockWhereString = this.getBlockWhere('replies', 'videoChannel').join(' AND ')
349
350 return `(` +
351 `SELECT COUNT("replies"."id") FROM "videoComment" AS "replies" ` +
352 `LEFT JOIN "video" ON "video"."id" = "replies"."videoId" ` +
353 `LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id" ` +
354 `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ` +
355 `AND "deletedAt" IS NULL ` +
356 `AND ${blockWhereString} ` +
357 `) AS "totalReplies"`
358 }
359
360 private getAuthorTotalRepliesSelect () {
361 return `(` +
362 `SELECT COUNT("replies"."id") FROM "videoComment" AS "replies" ` +
363 `INNER JOIN "video" ON "video"."id" = "replies"."videoId" ` +
364 `INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` +
365 `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" AND "replies"."accountId" = "videoChannel"."accountId"` +
366 `) AS "totalRepliesFromVideoAuthor"`
367 }
368
369 private getOrder () {
370 if (!this.options.sort) return ''
371
372 const orders = getCommentSort(this.options.sort)
373
374 return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ')
375 }
376
377 private getInnerLimit () {
378 if (!this.options.count) return ''
379
380 this.replacements.limit = this.options.count
381 this.replacements.offset = this.options.start || 0
382
383 return `LIMIT :limit OFFSET :offset `
384 }
385}
diff --git a/server/models/video/sql/comment/video-comment-table-attributes.ts b/server/models/video/sql/comment/video-comment-table-attributes.ts
new file mode 100644
index 000000000..87f8750c1
--- /dev/null
+++ b/server/models/video/sql/comment/video-comment-table-attributes.ts
@@ -0,0 +1,43 @@
1import { Memoize } from '@server/helpers/memoize'
2import { AccountModel } from '@server/models/account/account'
3import { ActorModel } from '@server/models/actor/actor'
4import { ActorImageModel } from '@server/models/actor/actor-image'
5import { ServerModel } from '@server/models/server/server'
6import { VideoCommentModel } from '../../video-comment'
7
8export class VideoCommentTableAttributes {
9
10 @Memoize()
11 getVideoCommentAttributes () {
12 return VideoCommentModel.getSQLAttributes('VideoCommentModel').join(', ')
13 }
14
15 @Memoize()
16 getAccountAttributes () {
17 return AccountModel.getSQLAttributes('Account', 'Account.').join(', ')
18 }
19
20 @Memoize()
21 getVideoAttributes () {
22 return [
23 `"Video"."id" AS "Video.id"`,
24 `"Video"."uuid" AS "Video.uuid"`,
25 `"Video"."name" AS "Video.name"`
26 ].join(', ')
27 }
28
29 @Memoize()
30 getActorAttributes () {
31 return ActorModel.getSQLAPIAttributes('Account->Actor', `Account.Actor.`).join(', ')
32 }
33
34 @Memoize()
35 getServerAttributes () {
36 return ServerModel.getSQLAttributes('Account->Actor->Server', `Account.Actor.Server.`).join(', ')
37 }
38
39 @Memoize()
40 getAvatarAttributes () {
41 return ActorImageModel.getSQLAttributes('Account->Actor->Avatars', 'Account.Actor.Avatars.').join(', ')
42 }
43}
diff --git a/server/models/video/sql/video/shared/abstract-video-query-builder.ts b/server/models/video/sql/video/shared/abstract-video-query-builder.ts
index f0ce69501..cbd57ad8c 100644
--- a/server/models/video/sql/video/shared/abstract-video-query-builder.ts
+++ b/server/models/video/sql/video/shared/abstract-video-query-builder.ts
@@ -1,9 +1,9 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import validator from 'validator' 2import validator from 'validator'
3import { createSafeIn } from '@server/models/utils'
4import { MUserAccountId } from '@server/types/models' 3import { MUserAccountId } from '@server/types/models'
5import { ActorImageType } from '@shared/models' 4import { ActorImageType } from '@shared/models'
6import { AbstractRunQuery } from '../../../../shared/abstract-run-query' 5import { AbstractRunQuery } from '../../../../shared/abstract-run-query'
6import { createSafeIn } from '../../../../shared'
7import { VideoTableAttributes } from './video-table-attributes' 7import { VideoTableAttributes } from './video-table-attributes'
8 8
9/** 9/**
diff --git a/server/models/video/sql/video/videos-id-list-query-builder.ts b/server/models/video/sql/video/videos-id-list-query-builder.ts
index 7c864bf27..62f1855c7 100644
--- a/server/models/video/sql/video/videos-id-list-query-builder.ts
+++ b/server/models/video/sql/video/videos-id-list-query-builder.ts
@@ -2,11 +2,12 @@ import { Sequelize, Transaction } from 'sequelize'
2import validator from 'validator' 2import validator from 'validator'
3import { exists } from '@server/helpers/custom-validators/misc' 3import { exists } from '@server/helpers/custom-validators/misc'
4import { WEBSERVER } from '@server/initializers/constants' 4import { WEBSERVER } from '@server/initializers/constants'
5import { buildDirectionAndField, createSafeIn, parseRowCountResult } from '@server/models/utils' 5import { buildSortDirectionAndField } from '@server/models/shared'
6import { MUserAccountId, MUserId } from '@server/types/models' 6import { MUserAccountId, MUserId } from '@server/types/models'
7import { forceNumber } from '@shared/core-utils'
7import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models' 8import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models'
9import { createSafeIn, parseRowCountResult } from '../../../shared'
8import { AbstractRunQuery } from '../../../shared/abstract-run-query' 10import { AbstractRunQuery } from '../../../shared/abstract-run-query'
9import { forceNumber } from '@shared/core-utils'
10 11
11/** 12/**
12 * 13 *
@@ -665,7 +666,7 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
665 } 666 }
666 667
667 private buildOrder (value: string) { 668 private buildOrder (value: string) {
668 const { direction, field } = buildDirectionAndField(value) 669 const { direction, field } = buildSortDirectionAndField(value)
669 if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field) 670 if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
670 671
671 if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()' 672 if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
diff --git a/server/models/video/tag.ts b/server/models/video/tag.ts
index 653b9694b..cebde3755 100644
--- a/server/models/video/tag.ts
+++ b/server/models/video/tag.ts
@@ -4,7 +4,7 @@ import { MTag } from '@server/types/models'
4import { AttributesOnly } from '@shared/typescript-utils' 4import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoPrivacy, VideoState } from '../../../shared/models/videos' 5import { VideoPrivacy, VideoState } from '../../../shared/models/videos'
6import { isVideoTagValid } from '../../helpers/custom-validators/videos' 6import { isVideoTagValid } from '../../helpers/custom-validators/videos'
7import { throwIfNotValid } from '../utils' 7import { throwIfNotValid } from '../shared'
8import { VideoModel } from './video' 8import { VideoModel } from './video'
9import { VideoTagModel } from './video-tag' 9import { VideoTagModel } from './video-tag'
10 10
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts
index 1cd8224c0..9247d0e2b 100644
--- a/server/models/video/video-blacklist.ts
+++ b/server/models/video/video-blacklist.ts
@@ -5,7 +5,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos' 5import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos'
6import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist' 6import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist'
7import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 7import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
8import { getBlacklistSort, searchAttribute, SortType, throwIfNotValid } from '../utils' 8import { getBlacklistSort, searchAttribute, throwIfNotValid } from '../shared'
9import { ThumbnailModel } from './thumbnail' 9import { ThumbnailModel } from './thumbnail'
10import { VideoModel } from './video' 10import { VideoModel } from './video'
11import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' 11import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
@@ -57,7 +57,7 @@ export class VideoBlacklistModel extends Model<Partial<AttributesOnly<VideoBlack
57 static listForApi (parameters: { 57 static listForApi (parameters: {
58 start: number 58 start: number
59 count: number 59 count: number
60 sort: SortType 60 sort: string
61 search?: string 61 search?: string
62 type?: VideoBlacklistType 62 type?: VideoBlacklistType
63 }) { 63 }) {
@@ -67,7 +67,7 @@ export class VideoBlacklistModel extends Model<Partial<AttributesOnly<VideoBlack
67 return { 67 return {
68 offset: start, 68 offset: start,
69 limit: count, 69 limit: count,
70 order: getBlacklistSort(sort.sortModel, sort.sortValue) 70 order: getBlacklistSort(sort)
71 } 71 }
72 } 72 }
73 73
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts
index 5fbcd6e3b..2eaa77407 100644
--- a/server/models/video/video-caption.ts
+++ b/server/models/video/video-caption.ts
@@ -23,7 +23,7 @@ import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/vid
23import { logger } from '../../helpers/logger' 23import { logger } from '../../helpers/logger'
24import { CONFIG } from '../../initializers/config' 24import { CONFIG } from '../../initializers/config'
25import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants' 25import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants'
26import { buildWhereIdOrUUID, throwIfNotValid } from '../utils' 26import { buildWhereIdOrUUID, throwIfNotValid } from '../shared'
27import { VideoModel } from './video' 27import { VideoModel } from './video'
28 28
29export enum ScopeNames { 29export enum ScopeNames {
diff --git a/server/models/video/video-change-ownership.ts b/server/models/video/video-change-ownership.ts
index 1a1b8c88d..2db4b523a 100644
--- a/server/models/video/video-change-ownership.ts
+++ b/server/models/video/video-change-ownership.ts
@@ -3,7 +3,7 @@ import { MVideoChangeOwnershipFormattable, MVideoChangeOwnershipFull } from '@se
3import { AttributesOnly } from '@shared/typescript-utils' 3import { AttributesOnly } from '@shared/typescript-utils'
4import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos' 4import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos'
5import { AccountModel } from '../account/account' 5import { AccountModel } from '../account/account'
6import { getSort } from '../utils' 6import { getSort } from '../shared'
7import { ScopeNames as VideoScopeNames, VideoModel } from './video' 7import { ScopeNames as VideoScopeNames, VideoModel } from './video'
8 8
9enum ScopeNames { 9enum ScopeNames {
diff --git a/server/models/video/video-channel-sync.ts b/server/models/video/video-channel-sync.ts
index 6e49cde10..a4cbf51f5 100644
--- a/server/models/video/video-channel-sync.ts
+++ b/server/models/video/video-channel-sync.ts
@@ -21,7 +21,7 @@ import { VideoChannelSync, VideoChannelSyncState } from '@shared/models'
21import { AttributesOnly } from '@shared/typescript-utils' 21import { AttributesOnly } from '@shared/typescript-utils'
22import { AccountModel } from '../account/account' 22import { AccountModel } from '../account/account'
23import { UserModel } from '../user/user' 23import { UserModel } from '../user/user'
24import { getChannelSyncSort, throwIfNotValid } from '../utils' 24import { getChannelSyncSort, throwIfNotValid } from '../shared'
25import { VideoChannelModel } from './video-channel' 25import { VideoChannelModel } from './video-channel'
26 26
27@DefaultScope(() => ({ 27@DefaultScope(() => ({
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 132c8f021..b71f5a197 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -43,8 +43,14 @@ import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
43import { ActorFollowModel } from '../actor/actor-follow' 43import { ActorFollowModel } from '../actor/actor-follow'
44import { ActorImageModel } from '../actor/actor-image' 44import { ActorImageModel } from '../actor/actor-image'
45import { ServerModel } from '../server/server' 45import { ServerModel } from '../server/server'
46import { setAsUpdated } from '../shared' 46import {
47import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' 47 buildServerIdsFollowedBy,
48 buildTrigramSearchIndex,
49 createSimilarityAttribute,
50 getSort,
51 setAsUpdated,
52 throwIfNotValid
53} from '../shared'
48import { VideoModel } from './video' 54import { VideoModel } from './video'
49import { VideoPlaylistModel } from './video-playlist' 55import { VideoPlaylistModel } from './video-playlist'
50 56
@@ -831,6 +837,6 @@ export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannel
831 } 837 }
832 838
833 setAsUpdated (transaction?: Transaction) { 839 setAsUpdated (transaction?: Transaction) {
834 return setAsUpdated('videoChannel', this.id, transaction) 840 return setAsUpdated({ sequelize: this.sequelize, table: 'videoChannel', id: this.id, transaction })
835 } 841 }
836} 842}
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index af9614d30..ff5142809 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -1,4 +1,4 @@
1import { FindOptions, Op, Order, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' 1import { FindOptions, Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
2import { 2import {
3 AllowNull, 3 AllowNull,
4 BelongsTo, 4 BelongsTo,
@@ -13,11 +13,9 @@ import {
13 Table, 13 Table,
14 UpdatedAt 14 UpdatedAt
15} from 'sequelize-typescript' 15} from 'sequelize-typescript'
16import { exists } from '@server/helpers/custom-validators/misc'
17import { getServerActor } from '@server/models/application/application' 16import { getServerActor } from '@server/models/application/application'
18import { MAccount, MAccountId, MUserAccountId } from '@server/types/models' 17import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
19import { uniqify } from '@shared/core-utils' 18import { pick, uniqify } from '@shared/core-utils'
20import { VideoPrivacy } from '@shared/models'
21import { AttributesOnly } from '@shared/typescript-utils' 19import { AttributesOnly } from '@shared/typescript-utils'
22import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' 20import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
23import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' 21import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
@@ -41,61 +39,19 @@ import {
41} from '../../types/models/video' 39} from '../../types/models/video'
42import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse' 40import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
43import { AccountModel } from '../account/account' 41import { AccountModel } from '../account/account'
44import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor' 42import { ActorModel } from '../actor/actor'
45import { 43import { buildLocalAccountIdsIn, buildSQLAttributes, throwIfNotValid } from '../shared'
46 buildBlockedAccountSQL, 44import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder'
47 buildBlockedAccountSQLOptimized,
48 buildLocalAccountIdsIn,
49 getCommentSort,
50 searchAttribute,
51 throwIfNotValid
52} from '../utils'
53import { VideoModel } from './video' 45import { VideoModel } from './video'
54import { VideoChannelModel } from './video-channel' 46import { VideoChannelModel } from './video-channel'
55 47
56export enum ScopeNames { 48export enum ScopeNames {
57 WITH_ACCOUNT = 'WITH_ACCOUNT', 49 WITH_ACCOUNT = 'WITH_ACCOUNT',
58 WITH_ACCOUNT_FOR_API = 'WITH_ACCOUNT_FOR_API',
59 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO', 50 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
60 WITH_VIDEO = 'WITH_VIDEO', 51 WITH_VIDEO = 'WITH_VIDEO'
61 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
62} 52}
63 53
64@Scopes(() => ({ 54@Scopes(() => ({
65 [ScopeNames.ATTRIBUTES_FOR_API]: (blockerAccountIds: number[]) => {
66 return {
67 attributes: {
68 include: [
69 [
70 Sequelize.literal(
71 '(' +
72 'WITH "blocklist" AS (' + buildBlockedAccountSQL(blockerAccountIds) + ')' +
73 'SELECT COUNT("replies"."id") ' +
74 'FROM "videoComment" AS "replies" ' +
75 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
76 'AND "deletedAt" IS NULL ' +
77 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
78 ')'
79 ),
80 'totalReplies'
81 ],
82 [
83 Sequelize.literal(
84 '(' +
85 'SELECT COUNT("replies"."id") ' +
86 'FROM "videoComment" AS "replies" ' +
87 'INNER JOIN "video" ON "video"."id" = "replies"."videoId" ' +
88 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
89 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
90 'AND "replies"."accountId" = "videoChannel"."accountId"' +
91 ')'
92 ),
93 'totalRepliesFromVideoAuthor'
94 ]
95 ]
96 }
97 } as FindOptions
98 },
99 [ScopeNames.WITH_ACCOUNT]: { 55 [ScopeNames.WITH_ACCOUNT]: {
100 include: [ 56 include: [
101 { 57 {
@@ -103,22 +59,6 @@ export enum ScopeNames {
103 } 59 }
104 ] 60 ]
105 }, 61 },
106 [ScopeNames.WITH_ACCOUNT_FOR_API]: {
107 include: [
108 {
109 model: AccountModel.unscoped(),
110 include: [
111 {
112 attributes: {
113 exclude: unusedActorAttributesForAPI
114 },
115 model: ActorModel, // Default scope includes avatar and server
116 required: true
117 }
118 ]
119 }
120 ]
121 },
122 [ScopeNames.WITH_IN_REPLY_TO]: { 62 [ScopeNames.WITH_IN_REPLY_TO]: {
123 include: [ 63 include: [
124 { 64 {
@@ -252,6 +192,18 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
252 }) 192 })
253 CommentAbuses: VideoCommentAbuseModel[] 193 CommentAbuses: VideoCommentAbuseModel[]
254 194
195 // ---------------------------------------------------------------------------
196
197 static getSQLAttributes (tableName: string, aliasPrefix = '') {
198 return buildSQLAttributes({
199 model: this,
200 tableName,
201 aliasPrefix
202 })
203 }
204
205 // ---------------------------------------------------------------------------
206
255 static loadById (id: number, t?: Transaction): Promise<MComment> { 207 static loadById (id: number, t?: Transaction): Promise<MComment> {
256 const query: FindOptions = { 208 const query: FindOptions = {
257 where: { 209 where: {
@@ -319,93 +271,19 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
319 searchAccount?: string 271 searchAccount?: string
320 searchVideo?: string 272 searchVideo?: string
321 }) { 273 }) {
322 const { start, count, sort, isLocal, search, searchAccount, searchVideo, onLocalVideo } = parameters 274 const queryOptions: ListVideoCommentsOptions = {
275 ...pick(parameters, [ 'start', 'count', 'sort', 'isLocal', 'search', 'searchVideo', 'searchAccount', 'onLocalVideo' ]),
323 276
324 const where: WhereOptions = { 277 selectType: 'api',
325 deletedAt: null 278 notDeleted: true
326 }
327
328 const whereAccount: WhereOptions = {}
329 const whereActor: WhereOptions = {}
330 const whereVideo: WhereOptions = {}
331
332 if (isLocal === true) {
333 Object.assign(whereActor, {
334 serverId: null
335 })
336 } else if (isLocal === false) {
337 Object.assign(whereActor, {
338 serverId: {
339 [Op.ne]: null
340 }
341 })
342 }
343
344 if (search) {
345 Object.assign(where, {
346 [Op.or]: [
347 searchAttribute(search, 'text'),
348 searchAttribute(search, '$Account.Actor.preferredUsername$'),
349 searchAttribute(search, '$Account.name$'),
350 searchAttribute(search, '$Video.name$')
351 ]
352 })
353 }
354
355 if (searchAccount) {
356 Object.assign(whereActor, {
357 [Op.or]: [
358 searchAttribute(searchAccount, '$Account.Actor.preferredUsername$'),
359 searchAttribute(searchAccount, '$Account.name$')
360 ]
361 })
362 }
363
364 if (searchVideo) {
365 Object.assign(whereVideo, searchAttribute(searchVideo, 'name'))
366 }
367
368 if (exists(onLocalVideo)) {
369 Object.assign(whereVideo, { remote: !onLocalVideo })
370 }
371
372 const getQuery = (forCount: boolean) => {
373 return {
374 offset: start,
375 limit: count,
376 order: getCommentSort(sort),
377 where,
378 include: [
379 {
380 model: AccountModel.unscoped(),
381 required: true,
382 where: whereAccount,
383 include: [
384 {
385 attributes: {
386 exclude: unusedActorAttributesForAPI
387 },
388 model: forCount === true
389 ? ActorModel.unscoped() // Default scope includes avatar and server
390 : ActorModel,
391 required: true,
392 where: whereActor
393 }
394 ]
395 },
396 {
397 model: VideoModel.unscoped(),
398 required: true,
399 where: whereVideo
400 }
401 ]
402 }
403 } 279 }
404 280
405 return Promise.all([ 281 return Promise.all([
406 VideoCommentModel.count(getQuery(true)), 282 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
407 VideoCommentModel.findAll(getQuery(false)) 283 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
408 ]).then(([ total, data ]) => ({ total, data })) 284 ]).then(([ rows, count ]) => {
285 return { total: count, data: rows }
286 })
409 } 287 }
410 288
411 static async listThreadsForApi (parameters: { 289 static async listThreadsForApi (parameters: {
@@ -416,67 +294,40 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
416 sort: string 294 sort: string
417 user?: MUserAccountId 295 user?: MUserAccountId
418 }) { 296 }) {
419 const { videoId, isVideoOwned, start, count, sort, user } = parameters 297 const { videoId, user } = parameters
420 298
421 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned }) 299 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
422 300
423 const accountBlockedWhere = { 301 const commonOptions: ListVideoCommentsOptions = {
424 accountId: { 302 selectType: 'api',
425 [Op.notIn]: Sequelize.literal( 303 videoId,
426 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')' 304 blockerAccountIds
427 )
428 }
429 } 305 }
430 306
431 const queryList = { 307 const listOptions: ListVideoCommentsOptions = {
432 offset: start, 308 ...commonOptions,
433 limit: count, 309 ...pick(parameters, [ 'sort', 'start', 'count' ]),
434 order: getCommentSort(sort), 310
435 where: { 311 isThread: true,
436 [Op.and]: [ 312 includeReplyCounters: true
437 {
438 videoId
439 },
440 {
441 inReplyToCommentId: null
442 },
443 {
444 [Op.or]: [
445 accountBlockedWhere,
446 {
447 accountId: null
448 }
449 ]
450 }
451 ]
452 }
453 } 313 }
454 314
455 const findScopesList: (string | ScopeOptions)[] = [ 315 const countOptions: ListVideoCommentsOptions = {
456 ScopeNames.WITH_ACCOUNT_FOR_API, 316 ...commonOptions,
457 {
458 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
459 }
460 ]
461 317
462 const countScopesList: ScopeOptions[] = [ 318 isThread: true
463 { 319 }
464 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
465 }
466 ]
467 320
468 const notDeletedQueryCount = { 321 const notDeletedCountOptions: ListVideoCommentsOptions = {
469 where: { 322 ...commonOptions,
470 videoId, 323
471 deletedAt: null, 324 notDeleted: true
472 ...accountBlockedWhere
473 }
474 } 325 }
475 326
476 return Promise.all([ 327 return Promise.all([
477 VideoCommentModel.scope(findScopesList).findAll(queryList), 328 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminFormattable>(),
478 VideoCommentModel.scope(countScopesList).count(queryList), 329 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, countOptions).countComments(),
479 VideoCommentModel.count(notDeletedQueryCount) 330 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, notDeletedCountOptions).countComments()
480 ]).then(([ rows, count, totalNotDeletedComments ]) => { 331 ]).then(([ rows, count, totalNotDeletedComments ]) => {
481 return { total: count, data: rows, totalNotDeletedComments } 332 return { total: count, data: rows, totalNotDeletedComments }
482 }) 333 })
@@ -484,54 +335,29 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
484 335
485 static async listThreadCommentsForApi (parameters: { 336 static async listThreadCommentsForApi (parameters: {
486 videoId: number 337 videoId: number
487 isVideoOwned: boolean
488 threadId: number 338 threadId: number
489 user?: MUserAccountId 339 user?: MUserAccountId
490 }) { 340 }) {
491 const { videoId, threadId, user, isVideoOwned } = parameters 341 const { user } = parameters
492 342
493 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned }) 343 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
494 344
495 const query = { 345 const queryOptions: ListVideoCommentsOptions = {
496 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order, 346 ...pick(parameters, [ 'videoId', 'threadId' ]),
497 where: {
498 videoId,
499 [Op.and]: [
500 {
501 [Op.or]: [
502 { id: threadId },
503 { originCommentId: threadId }
504 ]
505 },
506 {
507 [Op.or]: [
508 {
509 accountId: {
510 [Op.notIn]: Sequelize.literal(
511 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
512 )
513 }
514 },
515 {
516 accountId: null
517 }
518 ]
519 }
520 ]
521 }
522 }
523 347
524 const scopes: any[] = [ 348 selectType: 'api',
525 ScopeNames.WITH_ACCOUNT_FOR_API, 349 sort: 'createdAt',
526 { 350
527 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ] 351 blockerAccountIds,
528 } 352 includeReplyCounters: true
529 ] 353 }
530 354
531 return Promise.all([ 355 return Promise.all([
532 VideoCommentModel.count(query), 356 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
533 VideoCommentModel.scope(scopes).findAll(query) 357 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
534 ]).then(([ total, data ]) => ({ total, data })) 358 ]).then(([ rows, count ]) => {
359 return { total: count, data: rows }
360 })
535 } 361 }
536 362
537 static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> { 363 static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
@@ -559,31 +385,31 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
559 .findAll(query) 385 .findAll(query)
560 } 386 }
561 387
562 static async listAndCountByVideoForAP (video: MVideoImmutable, start: number, count: number, t?: Transaction) { 388 static async listAndCountByVideoForAP (parameters: {
563 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ 389 video: MVideoImmutable
390 start: number
391 count: number
392 }) {
393 const { video } = parameters
394
395 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
396
397 const queryOptions: ListVideoCommentsOptions = {
398 ...pick(parameters, [ 'start', 'count' ]),
399
400 selectType: 'comment-only',
564 videoId: video.id, 401 videoId: video.id,
565 isVideoOwned: video.isOwned() 402 sort: 'createdAt',
566 })
567 403
568 const query = { 404 blockerAccountIds
569 order: [ [ 'createdAt', 'ASC' ] ] as Order,
570 offset: start,
571 limit: count,
572 where: {
573 videoId: video.id,
574 accountId: {
575 [Op.notIn]: Sequelize.literal(
576 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
577 )
578 }
579 },
580 transaction: t
581 } 405 }
582 406
583 return Promise.all([ 407 return Promise.all([
584 VideoCommentModel.count(query), 408 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>(),
585 VideoCommentModel.findAll<MComment>(query) 409 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
586 ]).then(([ total, data ]) => ({ total, data })) 410 ]).then(([ rows, count ]) => {
411 return { total: count, data: rows }
412 })
587 } 413 }
588 414
589 static async listForFeed (parameters: { 415 static async listForFeed (parameters: {
@@ -592,97 +418,36 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
592 videoId?: number 418 videoId?: number
593 accountId?: number 419 accountId?: number
594 videoChannelId?: number 420 videoChannelId?: number
595 }): Promise<MCommentOwnerVideoFeed[]> { 421 }) {
596 const serverActor = await getServerActor() 422 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
597 const { start, count, videoId, accountId, videoChannelId } = parameters
598
599 const whereAnd: WhereOptions[] = buildBlockedAccountSQLOptimized(
600 '"VideoCommentModel"."accountId"',
601 [ serverActor.Account.id, '"Video->VideoChannel"."accountId"' ]
602 )
603 423
604 if (accountId) { 424 const queryOptions: ListVideoCommentsOptions = {
605 whereAnd.push({ 425 ...pick(parameters, [ 'start', 'count', 'accountId', 'videoId', 'videoChannelId' ]),
606 accountId
607 })
608 }
609 426
610 const accountWhere = { 427 selectType: 'feed',
611 [Op.and]: whereAnd
612 }
613 428
614 const videoChannelWhere = videoChannelId ? { id: videoChannelId } : undefined 429 sort: '-createdAt',
430 onPublicVideo: true,
431 notDeleted: true,
615 432
616 const query = { 433 blockerAccountIds
617 order: [ [ 'createdAt', 'DESC' ] ] as Order,
618 offset: start,
619 limit: count,
620 where: {
621 deletedAt: null,
622 accountId: accountWhere
623 },
624 include: [
625 {
626 attributes: [ 'name', 'uuid' ],
627 model: VideoModel.unscoped(),
628 required: true,
629 where: {
630 privacy: VideoPrivacy.PUBLIC
631 },
632 include: [
633 {
634 attributes: [ 'accountId' ],
635 model: VideoChannelModel.unscoped(),
636 required: true,
637 where: videoChannelWhere
638 }
639 ]
640 }
641 ]
642 } 434 }
643 435
644 if (videoId) query.where['videoId'] = videoId 436 return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentOwnerVideoFeed>()
645
646 return VideoCommentModel
647 .scope([ ScopeNames.WITH_ACCOUNT ])
648 .findAll(query)
649 } 437 }
650 438
651 static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) { 439 static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
652 const accountWhere = filter.onVideosOfAccount 440 const queryOptions: ListVideoCommentsOptions = {
653 ? { id: filter.onVideosOfAccount.id } 441 selectType: 'comment-only',
654 : {}
655 442
656 const query = { 443 accountId: ofAccount.id,
657 limit: 1000, 444 videoAccountOwnerId: filter.onVideosOfAccount?.id,
658 where: { 445
659 deletedAt: null, 446 notDeleted: true,
660 accountId: ofAccount.id 447 count: 5000
661 },
662 include: [
663 {
664 model: VideoModel,
665 required: true,
666 include: [
667 {
668 model: VideoChannelModel,
669 required: true,
670 include: [
671 {
672 model: AccountModel,
673 required: true,
674 where: accountWhere
675 }
676 ]
677 }
678 ]
679 }
680 ]
681 } 448 }
682 449
683 return VideoCommentModel 450 return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>()
684 .scope([ ScopeNames.WITH_ACCOUNT ])
685 .findAll(query)
686 } 451 }
687 452
688 static async getStats () { 453 static async getStats () {
@@ -750,9 +515,7 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
750 } 515 }
751 516
752 isOwned () { 517 isOwned () {
753 if (!this.Account) { 518 if (!this.Account) return false
754 return false
755 }
756 519
757 return this.Account.isOwned() 520 return this.Account.isOwned()
758 } 521 }
@@ -906,22 +669,15 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
906 } 669 }
907 670
908 private static async buildBlockerAccountIds (options: { 671 private static async buildBlockerAccountIds (options: {
909 videoId: number 672 user: MUserAccountId
910 isVideoOwned: boolean 673 }): Promise<number[]> {
911 user?: MUserAccountId 674 const { user } = options
912 }) {
913 const { videoId, user, isVideoOwned } = options
914 675
915 const serverActor = await getServerActor() 676 const serverActor = await getServerActor()
916 const blockerAccountIds = [ serverActor.Account.id ] 677 const blockerAccountIds = [ serverActor.Account.id ]
917 678
918 if (user) blockerAccountIds.push(user.Account.id) 679 if (user) blockerAccountIds.push(user.Account.id)
919 680
920 if (isVideoOwned) {
921 const videoOwnerAccount = await AccountModel.loadAccountIdFromVideo(videoId)
922 if (videoOwnerAccount) blockerAccountIds.push(videoOwnerAccount.id)
923 }
924
925 return blockerAccountIds 681 return blockerAccountIds
926 } 682 }
927} 683}
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index 9c4e6d078..07bc13de1 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -21,6 +21,7 @@ import {
21import validator from 'validator' 21import validator from 'validator'
22import { logger } from '@server/helpers/logger' 22import { logger } from '@server/helpers/logger'
23import { extractVideo } from '@server/helpers/video' 23import { extractVideo } from '@server/helpers/video'
24import { CONFIG } from '@server/initializers/config'
24import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url' 25import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url'
25import { 26import {
26 getHLSPrivateFileUrl, 27 getHLSPrivateFileUrl,
@@ -50,11 +51,9 @@ import {
50} from '../../initializers/constants' 51} from '../../initializers/constants'
51import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file' 52import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file'
52import { VideoRedundancyModel } from '../redundancy/video-redundancy' 53import { VideoRedundancyModel } from '../redundancy/video-redundancy'
53import { doesExist } from '../shared' 54import { doesExist, parseAggregateResult, throwIfNotValid } from '../shared'
54import { parseAggregateResult, throwIfNotValid } from '../utils'
55import { VideoModel } from './video' 55import { VideoModel } from './video'
56import { VideoStreamingPlaylistModel } from './video-streaming-playlist' 56import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
57import { CONFIG } from '@server/initializers/config'
58 57
59export enum ScopeNames { 58export enum ScopeNames {
60 WITH_VIDEO = 'WITH_VIDEO', 59 WITH_VIDEO = 'WITH_VIDEO',
@@ -266,7 +265,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
266 static doesInfohashExist (infoHash: string) { 265 static doesInfohashExist (infoHash: string) {
267 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1' 266 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
268 267
269 return doesExist(query, { infoHash }) 268 return doesExist(this.sequelize, query, { infoHash })
270 } 269 }
271 270
272 static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) { 271 static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) {
@@ -282,14 +281,14 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
282 'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' + 281 'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' +
283 'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webtorrent"."id" IS NOT NULL) LIMIT 1' 282 'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webtorrent"."id" IS NOT NULL) LIMIT 1'
284 283
285 return doesExist(query, { filename }) 284 return doesExist(this.sequelize, query, { filename })
286 } 285 }
287 286
288 static async doesOwnedWebTorrentVideoFileExist (filename: string) { 287 static async doesOwnedWebTorrentVideoFileExist (filename: string) {
289 const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' + 288 const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' +
290 `WHERE "filename" = $filename AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1` 289 `WHERE "filename" = $filename AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
291 290
292 return doesExist(query, { filename }) 291 return doesExist(this.sequelize, query, { filename })
293 } 292 }
294 293
295 static loadByFilename (filename: string) { 294 static loadByFilename (filename: string) {
@@ -439,7 +438,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
439 if (!element) return videoFile.save({ transaction }) 438 if (!element) return videoFile.save({ transaction })
440 439
441 for (const k of Object.keys(videoFile.toJSON())) { 440 for (const k of Object.keys(videoFile.toJSON())) {
442 element[k] = videoFile[k] 441 element.set(k, videoFile[k])
443 } 442 }
444 443
445 return element.save({ transaction }) 444 return element.save({ transaction })
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts
index da6b92c7a..c040e0fda 100644
--- a/server/models/video/video-import.ts
+++ b/server/models/video/video-import.ts
@@ -22,7 +22,7 @@ import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../help
22import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos' 22import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos'
23import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants' 23import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants'
24import { UserModel } from '../user/user' 24import { UserModel } from '../user/user'
25import { getSort, searchAttribute, throwIfNotValid } from '../utils' 25import { getSort, searchAttribute, throwIfNotValid } from '../shared'
26import { ScopeNames as VideoModelScopeNames, VideoModel } from './video' 26import { ScopeNames as VideoModelScopeNames, VideoModel } from './video'
27import { VideoChannelSyncModel } from './video-channel-sync' 27import { VideoChannelSyncModel } from './video-channel-sync'
28 28
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
index 7181b5599..48f4ed5a9 100644
--- a/server/models/video/video-playlist-element.ts
+++ b/server/models/video/video-playlist-element.ts
@@ -31,7 +31,7 @@ import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../shared/
31import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 31import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
32import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 32import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
33import { AccountModel } from '../account/account' 33import { AccountModel } from '../account/account'
34import { getSort, throwIfNotValid } from '../utils' 34import { getSort, throwIfNotValid } from '../shared'
35import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video' 35import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video'
36import { VideoPlaylistModel } from './video-playlist' 36import { VideoPlaylistModel } from './video-playlist'
37 37
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
index 8bbe54c49..faf4bea78 100644
--- a/server/models/video/video-playlist.ts
+++ b/server/models/video/video-playlist.ts
@@ -21,12 +21,8 @@ import { activityPubCollectionPagination } from '@server/lib/activitypub/collect
21import { MAccountId, MChannelId } from '@server/types/models' 21import { MAccountId, MChannelId } from '@server/types/models'
22import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils' 22import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils'
23import { buildUUID, uuidToShort } from '@shared/extra-utils' 23import { buildUUID, uuidToShort } from '@shared/extra-utils'
24import { ActivityIconObject, PlaylistObject, VideoPlaylist, VideoPlaylistPrivacy, VideoPlaylistType } from '@shared/models'
24import { AttributesOnly } from '@shared/typescript-utils' 25import { AttributesOnly } from '@shared/typescript-utils'
25import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
26import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
27import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
28import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
29import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
30import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 26import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
31import { 27import {
32 isVideoPlaylistDescriptionValid, 28 isVideoPlaylistDescriptionValid,
@@ -53,7 +49,6 @@ import {
53} from '../../types/models/video/video-playlist' 49} from '../../types/models/video/video-playlist'
54import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account' 50import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
55import { ActorModel } from '../actor/actor' 51import { ActorModel } from '../actor/actor'
56import { setAsUpdated } from '../shared'
57import { 52import {
58 buildServerIdsFollowedBy, 53 buildServerIdsFollowedBy,
59 buildTrigramSearchIndex, 54 buildTrigramSearchIndex,
@@ -61,8 +56,9 @@ import {
61 createSimilarityAttribute, 56 createSimilarityAttribute,
62 getPlaylistSort, 57 getPlaylistSort,
63 isOutdated, 58 isOutdated,
59 setAsUpdated,
64 throwIfNotValid 60 throwIfNotValid
65} from '../utils' 61} from '../shared'
66import { ThumbnailModel } from './thumbnail' 62import { ThumbnailModel } from './thumbnail'
67import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' 63import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
68import { VideoPlaylistElementModel } from './video-playlist-element' 64import { VideoPlaylistElementModel } from './video-playlist-element'
@@ -641,7 +637,7 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
641 } 637 }
642 638
643 setAsRefreshed () { 639 setAsRefreshed () {
644 return setAsUpdated('videoPlaylist', this.id) 640 return setAsUpdated({ sequelize: this.sequelize, table: 'videoPlaylist', id: this.id })
645 } 641 }
646 642
647 setVideosLength (videosLength: number) { 643 setVideosLength (videosLength: number) {
diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts
index f2190037e..b4de2b20f 100644
--- a/server/models/video/video-share.ts
+++ b/server/models/video/video-share.ts
@@ -7,7 +7,7 @@ import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
7import { MActorDefault, MActorFollowersUrl, MActorId } from '../../types/models' 7import { MActorDefault, MActorFollowersUrl, MActorId } from '../../types/models'
8import { MVideoShareActor, MVideoShareFull } from '../../types/models/video' 8import { MVideoShareActor, MVideoShareFull } from '../../types/models/video'
9import { ActorModel } from '../actor/actor' 9import { ActorModel } from '../actor/actor'
10import { buildLocalActorIdsIn, throwIfNotValid } from '../utils' 10import { buildLocalActorIdsIn, throwIfNotValid } from '../shared'
11import { VideoModel } from './video' 11import { VideoModel } from './video'
12 12
13enum ScopeNames { 13enum ScopeNames {
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
index 0386edf28..a85c79c9f 100644
--- a/server/models/video/video-streaming-playlist.ts
+++ b/server/models/video/video-streaming-playlist.ts
@@ -37,8 +37,7 @@ import {
37 WEBSERVER 37 WEBSERVER
38} from '../../initializers/constants' 38} from '../../initializers/constants'
39import { VideoRedundancyModel } from '../redundancy/video-redundancy' 39import { VideoRedundancyModel } from '../redundancy/video-redundancy'
40import { doesExist } from '../shared' 40import { doesExist, throwIfNotValid } from '../shared'
41import { throwIfNotValid } from '../utils'
42import { VideoModel } from './video' 41import { VideoModel } from './video'
43 42
44@Table({ 43@Table({
@@ -138,7 +137,7 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
138 static doesInfohashExist (infoHash: string) { 137 static doesInfohashExist (infoHash: string) {
139 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1' 138 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1'
140 139
141 return doesExist(query, { infoHash }) 140 return doesExist(this.sequelize, query, { infoHash })
142 } 141 }
143 142
144 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, files: unknown[]) { 143 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, files: unknown[]) {
@@ -237,7 +236,7 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
237 `AND "video"."remote" IS FALSE AND "video"."uuid" = $videoUUID ` + 236 `AND "video"."remote" IS FALSE AND "video"."uuid" = $videoUUID ` +
238 `AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1` 237 `AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
239 238
240 return doesExist(query, { videoUUID }) 239 return doesExist(this.sequelize, query, { videoUUID })
241 } 240 }
242 241
243 assignP2PMediaLoaderInfoHashes (video: MVideo, files: unknown[]) { 242 assignP2PMediaLoaderInfoHashes (video: MVideo, files: unknown[]) {
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 56cc45cfe..1a10d2da2 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -32,7 +32,7 @@ import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFil
32import { VideoPathManager } from '@server/lib/video-path-manager' 32import { VideoPathManager } from '@server/lib/video-path-manager'
33import { isVideoInPrivateDirectory } from '@server/lib/video-privacy' 33import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
34import { getServerActor } from '@server/models/application/application' 34import { getServerActor } from '@server/models/application/application'
35import { ModelCache } from '@server/models/model-cache' 35import { ModelCache } from '@server/models/shared/model-cache'
36import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils' 36import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
37import { ffprobePromise, getAudioStream, hasAudioStream, uuidToShort } from '@shared/extra-utils' 37import { ffprobePromise, getAudioStream, hasAudioStream, uuidToShort } from '@shared/extra-utils'
38import { 38import {
@@ -103,10 +103,9 @@ import { VideoRedundancyModel } from '../redundancy/video-redundancy'
103import { ServerModel } from '../server/server' 103import { ServerModel } from '../server/server'
104import { TrackerModel } from '../server/tracker' 104import { TrackerModel } from '../server/tracker'
105import { VideoTrackerModel } from '../server/video-tracker' 105import { VideoTrackerModel } from '../server/video-tracker'
106import { setAsUpdated } from '../shared' 106import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, setAsUpdated, throwIfNotValid } from '../shared'
107import { UserModel } from '../user/user' 107import { UserModel } from '../user/user'
108import { UserVideoHistoryModel } from '../user/user-video-history' 108import { UserVideoHistoryModel } from '../user/user-video-history'
109import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
110import { VideoViewModel } from '../view/video-view' 109import { VideoViewModel } from '../view/video-view'
111import { 110import {
112 videoFilesModelToFormattedJSON, 111 videoFilesModelToFormattedJSON,
@@ -1871,7 +1870,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1871 } 1870 }
1872 1871
1873 setAsRefreshed (transaction?: Transaction) { 1872 setAsRefreshed (transaction?: Transaction) {
1874 return setAsUpdated('video', this.id, transaction) 1873 return setAsUpdated({ sequelize: this.sequelize, table: 'video', id: this.id, transaction })
1875 } 1874 }
1876 1875
1877 // --------------------------------------------------------------------------- 1876 // ---------------------------------------------------------------------------
diff --git a/server/tests/api/activitypub/cleaner.ts b/server/tests/api/activitypub/cleaner.ts
index eb6779123..1c1495022 100644
--- a/server/tests/api/activitypub/cleaner.ts
+++ b/server/tests/api/activitypub/cleaner.ts
@@ -148,7 +148,7 @@ describe('Test AP cleaner', function () {
148 it('Should destroy server 3 internal shares and correctly clean them', async function () { 148 it('Should destroy server 3 internal shares and correctly clean them', async function () {
149 this.timeout(20000) 149 this.timeout(20000)
150 150
151 const preCount = await servers[0].sql.getCount('videoShare') 151 const preCount = await servers[0].sql.getVideoShareCount()
152 expect(preCount).to.equal(6) 152 expect(preCount).to.equal(6)
153 153
154 await servers[2].sql.deleteAll('videoShare') 154 await servers[2].sql.deleteAll('videoShare')
@@ -156,7 +156,7 @@ describe('Test AP cleaner', function () {
156 await waitJobs(servers) 156 await waitJobs(servers)
157 157
158 // Still 6 because we don't have remote shares on local videos 158 // Still 6 because we don't have remote shares on local videos
159 const postCount = await servers[0].sql.getCount('videoShare') 159 const postCount = await servers[0].sql.getVideoShareCount()
160 expect(postCount).to.equal(6) 160 expect(postCount).to.equal(6)
161 }) 161 })
162 162
@@ -185,7 +185,7 @@ describe('Test AP cleaner', function () {
185 async function check (like: string, ofServerUrl: string, urlSuffix: string, remote: 'true' | 'false') { 185 async function check (like: string, ofServerUrl: string, urlSuffix: string, remote: 'true' | 'false') {
186 const query = `SELECT "videoId", "accountVideoRate".url FROM "accountVideoRate" ` + 186 const query = `SELECT "videoId", "accountVideoRate".url FROM "accountVideoRate" ` +
187 `INNER JOIN video ON "accountVideoRate"."videoId" = video.id AND remote IS ${remote} WHERE "accountVideoRate"."url" LIKE '${like}'` 187 `INNER JOIN video ON "accountVideoRate"."videoId" = video.id AND remote IS ${remote} WHERE "accountVideoRate"."url" LIKE '${like}'`
188 const res = await servers[0].sql.selectQuery(query) 188 const res = await servers[0].sql.selectQuery<{ url: string }>(query)
189 189
190 for (const rate of res) { 190 for (const rate of res) {
191 const matcher = new RegExp(`^${ofServerUrl}/accounts/root/dislikes/\\d+${urlSuffix}$`) 191 const matcher = new RegExp(`^${ofServerUrl}/accounts/root/dislikes/\\d+${urlSuffix}$`)
@@ -231,7 +231,7 @@ describe('Test AP cleaner', function () {
231 const query = `SELECT "videoId", "videoComment".url, uuid as "videoUUID" FROM "videoComment" ` + 231 const query = `SELECT "videoId", "videoComment".url, uuid as "videoUUID" FROM "videoComment" ` +
232 `INNER JOIN video ON "videoComment"."videoId" = video.id AND remote IS ${remote} WHERE "videoComment"."url" LIKE '${like}'` 232 `INNER JOIN video ON "videoComment"."videoId" = video.id AND remote IS ${remote} WHERE "videoComment"."url" LIKE '${like}'`
233 233
234 const res = await servers[0].sql.selectQuery(query) 234 const res = await servers[0].sql.selectQuery<{ url: string, videoUUID: string }>(query)
235 235
236 for (const comment of res) { 236 for (const comment of res) {
237 const matcher = new RegExp(`${ofServerUrl}/videos/watch/${comment.videoUUID}/comments/\\d+${urlSuffix}`) 237 const matcher = new RegExp(`${ofServerUrl}/videos/watch/${comment.videoUUID}/comments/\\d+${urlSuffix}`)
diff --git a/server/tests/api/check-params/redundancy.ts b/server/tests/api/check-params/redundancy.ts
index 908407b9a..73dfd489d 100644
--- a/server/tests/api/check-params/redundancy.ts
+++ b/server/tests/api/check-params/redundancy.ts
@@ -24,7 +24,7 @@ describe('Test server redundancy API validators', function () {
24 // --------------------------------------------------------------- 24 // ---------------------------------------------------------------
25 25
26 before(async function () { 26 before(async function () {
27 this.timeout(80000) 27 this.timeout(160000)
28 28
29 servers = await createMultipleServers(2) 29 servers = await createMultipleServers(2)
30 30
diff --git a/server/tests/api/live/live-fast-restream.ts b/server/tests/api/live/live-fast-restream.ts
index c0bb8d529..f6959b83c 100644
--- a/server/tests/api/live/live-fast-restream.ts
+++ b/server/tests/api/live/live-fast-restream.ts
@@ -78,9 +78,15 @@ describe('Fast restream in live', function () {
78 const video = await server.videos.get({ id: liveId }) 78 const video = await server.videos.get({ id: liveId })
79 expect(video.streamingPlaylists).to.have.lengthOf(1) 79 expect(video.streamingPlaylists).to.have.lengthOf(1)
80 80
81 await server.live.getSegmentFile({ videoUUID: liveId, segment: 0, playlistNumber: 0 }) 81 try {
82 await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) 82 await server.live.getSegmentFile({ videoUUID: liveId, segment: 0, playlistNumber: 0 })
83 await makeRawRequest({ url: video.streamingPlaylists[0].segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) 83 await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 })
84 await makeRawRequest({ url: video.streamingPlaylists[0].segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 })
85 } catch (err) {
86 // FIXME: try to debug error in CI "Unexpected end of JSON input"
87 console.error(err)
88 throw err
89 }
84 90
85 await wait(100) 91 await wait(100)
86 } 92 }
@@ -129,7 +135,7 @@ describe('Fast restream in live', function () {
129 await server.config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' }) 135 await server.config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' })
130 }) 136 })
131 137
132 it('Should correctly fast reastream in a permanent live with and without save replay', async function () { 138 it('Should correctly fast restream in a permanent live with and without save replay', async function () {
133 this.timeout(480000) 139 this.timeout(480000)
134 140
135 // A test can take a long time, so prefer to run them in parallel 141 // A test can take a long time, so prefer to run them in parallel
diff --git a/server/tests/api/notifications/moderation-notifications.ts b/server/tests/api/notifications/moderation-notifications.ts
index b127a7a31..c7b9b5fb0 100644
--- a/server/tests/api/notifications/moderation-notifications.ts
+++ b/server/tests/api/notifications/moderation-notifications.ts
@@ -34,7 +34,7 @@ describe('Test moderation notifications', function () {
34 let emails: object[] = [] 34 let emails: object[] = []
35 35
36 before(async function () { 36 before(async function () {
37 this.timeout(120000) 37 this.timeout(50000)
38 38
39 const res = await prepareNotificationsTest(3) 39 const res = await prepareNotificationsTest(3)
40 emails = res.emails 40 emails = res.emails
@@ -60,7 +60,7 @@ describe('Test moderation notifications', function () {
60 }) 60 })
61 61
62 it('Should not send a notification to moderators on local abuse reported by an admin', async function () { 62 it('Should not send a notification to moderators on local abuse reported by an admin', async function () {
63 this.timeout(20000) 63 this.timeout(50000)
64 64
65 const name = 'video for abuse ' + buildUUID() 65 const name = 'video for abuse ' + buildUUID()
66 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 66 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -72,7 +72,7 @@ describe('Test moderation notifications', function () {
72 }) 72 })
73 73
74 it('Should send a notification to moderators on local video abuse', async function () { 74 it('Should send a notification to moderators on local video abuse', async function () {
75 this.timeout(20000) 75 this.timeout(50000)
76 76
77 const name = 'video for abuse ' + buildUUID() 77 const name = 'video for abuse ' + buildUUID()
78 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 78 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -84,7 +84,7 @@ describe('Test moderation notifications', function () {
84 }) 84 })
85 85
86 it('Should send a notification to moderators on remote video abuse', async function () { 86 it('Should send a notification to moderators on remote video abuse', async function () {
87 this.timeout(20000) 87 this.timeout(50000)
88 88
89 const name = 'video for abuse ' + buildUUID() 89 const name = 'video for abuse ' + buildUUID()
90 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 90 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -99,7 +99,7 @@ describe('Test moderation notifications', function () {
99 }) 99 })
100 100
101 it('Should send a notification to moderators on local comment abuse', async function () { 101 it('Should send a notification to moderators on local comment abuse', async function () {
102 this.timeout(20000) 102 this.timeout(50000)
103 103
104 const name = 'video for abuse ' + buildUUID() 104 const name = 'video for abuse ' + buildUUID()
105 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 105 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -118,7 +118,7 @@ describe('Test moderation notifications', function () {
118 }) 118 })
119 119
120 it('Should send a notification to moderators on remote comment abuse', async function () { 120 it('Should send a notification to moderators on remote comment abuse', async function () {
121 this.timeout(20000) 121 this.timeout(50000)
122 122
123 const name = 'video for abuse ' + buildUUID() 123 const name = 'video for abuse ' + buildUUID()
124 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 124 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -140,7 +140,7 @@ describe('Test moderation notifications', function () {
140 }) 140 })
141 141
142 it('Should send a notification to moderators on local account abuse', async function () { 142 it('Should send a notification to moderators on local account abuse', async function () {
143 this.timeout(20000) 143 this.timeout(50000)
144 144
145 const username = 'user' + new Date().getTime() 145 const username = 'user' + new Date().getTime()
146 const { account } = await servers[0].users.create({ username, password: 'donald' }) 146 const { account } = await servers[0].users.create({ username, password: 'donald' })
@@ -153,7 +153,7 @@ describe('Test moderation notifications', function () {
153 }) 153 })
154 154
155 it('Should send a notification to moderators on remote account abuse', async function () { 155 it('Should send a notification to moderators on remote account abuse', async function () {
156 this.timeout(20000) 156 this.timeout(50000)
157 157
158 const username = 'user' + new Date().getTime() 158 const username = 'user' + new Date().getTime()
159 const tmpToken = await servers[0].users.generateUserAndToken(username) 159 const tmpToken = await servers[0].users.generateUserAndToken(username)
@@ -512,10 +512,14 @@ describe('Test moderation notifications', function () {
512 }) 512 })
513 513
514 it('Should not send video publish notification if auto-blacklisted', async function () { 514 it('Should not send video publish notification if auto-blacklisted', async function () {
515 this.timeout(120000)
516
515 await checkVideoIsPublished({ ...userBaseParams, videoName, shortUUID, checkType: 'absence' }) 517 await checkVideoIsPublished({ ...userBaseParams, videoName, shortUUID, checkType: 'absence' })
516 }) 518 })
517 519
518 it('Should not send a local user subscription notification if auto-blacklisted', async function () { 520 it('Should not send a local user subscription notification if auto-blacklisted', async function () {
521 this.timeout(120000)
522
519 await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'absence' }) 523 await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'absence' })
520 }) 524 })
521 525
@@ -524,7 +528,7 @@ describe('Test moderation notifications', function () {
524 }) 528 })
525 529
526 it('Should send video published and unblacklist after video unblacklisted', async function () { 530 it('Should send video published and unblacklist after video unblacklisted', async function () {
527 this.timeout(40000) 531 this.timeout(120000)
528 532
529 await servers[0].blacklist.remove({ videoId: uuid }) 533 await servers[0].blacklist.remove({ videoId: uuid })
530 534
@@ -537,10 +541,14 @@ describe('Test moderation notifications', function () {
537 }) 541 })
538 542
539 it('Should send a local user subscription notification after removed from blacklist', async function () { 543 it('Should send a local user subscription notification after removed from blacklist', async function () {
544 this.timeout(120000)
545
540 await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'presence' }) 546 await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'presence' })
541 }) 547 })
542 548
543 it('Should send a remote user subscription notification after removed from blacklist', async function () { 549 it('Should send a remote user subscription notification after removed from blacklist', async function () {
550 this.timeout(120000)
551
544 await checkNewVideoFromSubscription({ ...adminBaseParamsServer2, videoName, shortUUID, checkType: 'presence' }) 552 await checkNewVideoFromSubscription({ ...adminBaseParamsServer2, videoName, shortUUID, checkType: 'presence' })
545 }) 553 })
546 554
@@ -576,7 +584,7 @@ describe('Test moderation notifications', function () {
576 }) 584 })
577 585
578 it('Should not send publish/subscription notifications after scheduled update if video still auto-blacklisted', async function () { 586 it('Should not send publish/subscription notifications after scheduled update if video still auto-blacklisted', async function () {
579 this.timeout(40000) 587 this.timeout(120000)
580 588
581 // In 2 seconds 589 // In 2 seconds
582 const updateAt = new Date(new Date().getTime() + 2000) 590 const updateAt = new Date(new Date().getTime() + 2000)
diff --git a/server/tests/api/object-storage/video-static-file-privacy.ts b/server/tests/api/object-storage/video-static-file-privacy.ts
index 71ad35a43..869d437d5 100644
--- a/server/tests/api/object-storage/video-static-file-privacy.ts
+++ b/server/tests/api/object-storage/video-static-file-privacy.ts
@@ -120,7 +120,7 @@ describe('Object storage for video static file privacy', function () {
120 // --------------------------------------------------------------------------- 120 // ---------------------------------------------------------------------------
121 121
122 it('Should upload a private video and have appropriate object storage ACL', async function () { 122 it('Should upload a private video and have appropriate object storage ACL', async function () {
123 this.timeout(60000) 123 this.timeout(120000)
124 124
125 { 125 {
126 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) 126 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
@@ -138,7 +138,7 @@ describe('Object storage for video static file privacy', function () {
138 }) 138 })
139 139
140 it('Should upload a public video and have appropriate object storage ACL', async function () { 140 it('Should upload a public video and have appropriate object storage ACL', async function () {
141 this.timeout(60000) 141 this.timeout(120000)
142 142
143 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.UNLISTED }) 143 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.UNLISTED })
144 await waitJobs([ server ]) 144 await waitJobs([ server ])
diff --git a/server/tests/api/users/index.ts b/server/tests/api/users/index.ts
index 643f1a531..0313845ef 100644
--- a/server/tests/api/users/index.ts
+++ b/server/tests/api/users/index.ts
@@ -1,3 +1,4 @@
1import './oauth'
1import './two-factor' 2import './two-factor'
2import './user-subscriptions' 3import './user-subscriptions'
3import './user-videos' 4import './user-videos'
diff --git a/server/tests/api/users/oauth.ts b/server/tests/api/users/oauth.ts
new file mode 100644
index 000000000..6a3da5ea2
--- /dev/null
+++ b/server/tests/api/users/oauth.ts
@@ -0,0 +1,192 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { wait } from '@shared/core-utils'
5import { HttpStatusCode, OAuth2ErrorCode, PeerTubeProblemDocument } from '@shared/models'
6import { cleanupTests, createSingleServer, killallServers, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
7
8describe('Test oauth', function () {
9 let server: PeerTubeServer
10
11 before(async function () {
12 this.timeout(30000)
13
14 server = await createSingleServer(1, {
15 rates_limit: {
16 login: {
17 max: 30
18 }
19 }
20 })
21
22 await setAccessTokensToServers([ server ])
23 })
24
25 describe('OAuth client', function () {
26
27 function expectInvalidClient (body: PeerTubeProblemDocument) {
28 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
29 expect(body.error).to.contain('client is invalid')
30 expect(body.type.startsWith('https://')).to.be.true
31 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
32 }
33
34 it('Should create a new client')
35
36 it('Should return the first client')
37
38 it('Should remove the last client')
39
40 it('Should not login with an invalid client id', async function () {
41 const client = { id: 'client', secret: server.store.client.secret }
42 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
43
44 expectInvalidClient(body)
45 })
46
47 it('Should not login with an invalid client secret', async function () {
48 const client = { id: server.store.client.id, secret: 'coucou' }
49 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
50
51 expectInvalidClient(body)
52 })
53 })
54
55 describe('Login', function () {
56
57 function expectInvalidCredentials (body: PeerTubeProblemDocument) {
58 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
59 expect(body.error).to.contain('credentials are invalid')
60 expect(body.type.startsWith('https://')).to.be.true
61 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
62 }
63
64 it('Should not login with an invalid username', async function () {
65 const user = { username: 'captain crochet', password: server.store.user.password }
66 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
67
68 expectInvalidCredentials(body)
69 })
70
71 it('Should not login with an invalid password', async function () {
72 const user = { username: server.store.user.username, password: 'mew_three' }
73 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
74
75 expectInvalidCredentials(body)
76 })
77
78 it('Should be able to login', async function () {
79 await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
80 })
81
82 it('Should be able to login with an insensitive username', async function () {
83 const user = { username: 'RoOt', password: server.store.user.password }
84 await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
85
86 const user2 = { username: 'rOoT', password: server.store.user.password }
87 await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
88
89 const user3 = { username: 'ROOt', password: server.store.user.password }
90 await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
91 })
92 })
93
94 describe('Logout', function () {
95
96 it('Should logout (revoke token)', async function () {
97 await server.login.logout({ token: server.accessToken })
98 })
99
100 it('Should not be able to get the user information', async function () {
101 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
102 })
103
104 it('Should not be able to upload a video', async function () {
105 await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
106 })
107
108 it('Should be able to login again', async function () {
109 const body = await server.login.login()
110 server.accessToken = body.access_token
111 server.refreshToken = body.refresh_token
112 })
113
114 it('Should be able to get my user information again', async function () {
115 await server.users.getMyInfo()
116 })
117
118 it('Should have an expired access token', async function () {
119 this.timeout(60000)
120
121 await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
122 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
123
124 await killallServers([ server ])
125 await server.run()
126
127 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
128 })
129
130 it('Should not be able to refresh an access token with an expired refresh token', async function () {
131 await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
132 })
133
134 it('Should refresh the token', async function () {
135 this.timeout(50000)
136
137 const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
138 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
139
140 await killallServers([ server ])
141 await server.run()
142
143 const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
144 server.accessToken = res.body.access_token
145 server.refreshToken = res.body.refresh_token
146 })
147
148 it('Should be able to get my user information again', async function () {
149 await server.users.getMyInfo()
150 })
151 })
152
153 describe('Custom token lifetime', function () {
154 before(async function () {
155 this.timeout(120_000)
156
157 await server.kill()
158 await server.run({
159 oauth2: {
160 token_lifetime: {
161 access_token: '2 seconds',
162 refresh_token: '2 seconds'
163 }
164 }
165 })
166 })
167
168 it('Should have a very short access token lifetime', async function () {
169 this.timeout(50000)
170
171 const { access_token: accessToken } = await server.login.login()
172 await server.users.getMyInfo({ token: accessToken })
173
174 await wait(3000)
175 await server.users.getMyInfo({ token: accessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
176 })
177
178 it('Should have a very short refresh token lifetime', async function () {
179 this.timeout(50000)
180
181 const { refresh_token: refreshToken } = await server.login.login()
182 await server.login.refreshToken({ refreshToken })
183
184 await wait(3000)
185 await server.login.refreshToken({ refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
186 })
187 })
188
189 after(async function () {
190 await cleanupTests([ server ])
191 })
192})
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts
index 421b3ce16..93e2e489a 100644
--- a/server/tests/api/users/users.ts
+++ b/server/tests/api/users/users.ts
@@ -2,15 +2,8 @@
2 2
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { testImage } from '@server/tests/shared' 4import { testImage } from '@server/tests/shared'
5import { AbuseState, HttpStatusCode, OAuth2ErrorCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models' 5import { AbuseState, HttpStatusCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models'
6import { 6import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
7 cleanupTests,
8 createSingleServer,
9 killallServers,
10 makePutBodyRequest,
11 PeerTubeServer,
12 setAccessTokensToServers
13} from '@shared/server-commands'
14 7
15describe('Test users', function () { 8describe('Test users', function () {
16 let server: PeerTubeServer 9 let server: PeerTubeServer
@@ -39,166 +32,6 @@ describe('Test users', function () {
39 await server.plugins.install({ npmName: 'peertube-theme-background-red' }) 32 await server.plugins.install({ npmName: 'peertube-theme-background-red' })
40 }) 33 })
41 34
42 describe('OAuth client', function () {
43 it('Should create a new client')
44
45 it('Should return the first client')
46
47 it('Should remove the last client')
48
49 it('Should not login with an invalid client id', async function () {
50 const client = { id: 'client', secret: server.store.client.secret }
51 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
52
53 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
54 expect(body.error).to.contain('client is invalid')
55 expect(body.type.startsWith('https://')).to.be.true
56 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
57 })
58
59 it('Should not login with an invalid client secret', async function () {
60 const client = { id: server.store.client.id, secret: 'coucou' }
61 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
62
63 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
64 expect(body.error).to.contain('client is invalid')
65 expect(body.type.startsWith('https://')).to.be.true
66 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
67 })
68 })
69
70 describe('Login', function () {
71
72 it('Should not login with an invalid username', async function () {
73 const user = { username: 'captain crochet', password: server.store.user.password }
74 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
75
76 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
77 expect(body.error).to.contain('credentials are invalid')
78 expect(body.type.startsWith('https://')).to.be.true
79 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
80 })
81
82 it('Should not login with an invalid password', async function () {
83 const user = { username: server.store.user.username, password: 'mew_three' }
84 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
85
86 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
87 expect(body.error).to.contain('credentials are invalid')
88 expect(body.type.startsWith('https://')).to.be.true
89 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
90 })
91
92 it('Should not be able to upload a video', async function () {
93 token = 'my_super_token'
94
95 await server.videos.upload({ token, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
96 })
97
98 it('Should not be able to follow', async function () {
99 token = 'my_super_token'
100
101 await server.follows.follow({
102 hosts: [ 'http://example.com' ],
103 token,
104 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
105 })
106 })
107
108 it('Should not be able to unfollow')
109
110 it('Should be able to login', async function () {
111 const body = await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
112
113 token = body.access_token
114 })
115
116 it('Should be able to login with an insensitive username', async function () {
117 const user = { username: 'RoOt', password: server.store.user.password }
118 await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
119
120 const user2 = { username: 'rOoT', password: server.store.user.password }
121 await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
122
123 const user3 = { username: 'ROOt', password: server.store.user.password }
124 await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
125 })
126 })
127
128 describe('Logout', function () {
129 it('Should logout (revoke token)', async function () {
130 await server.login.logout({ token: server.accessToken })
131 })
132
133 it('Should not be able to get the user information', async function () {
134 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
135 })
136
137 it('Should not be able to upload a video', async function () {
138 await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
139 })
140
141 it('Should not be able to rate a video', async function () {
142 const path = '/api/v1/videos/'
143 const data = {
144 rating: 'likes'
145 }
146
147 const options = {
148 url: server.url,
149 path: path + videoId,
150 token: 'wrong token',
151 fields: data,
152 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
153 }
154 await makePutBodyRequest(options)
155 })
156
157 it('Should be able to login again', async function () {
158 const body = await server.login.login()
159 server.accessToken = body.access_token
160 server.refreshToken = body.refresh_token
161 })
162
163 it('Should be able to get my user information again', async function () {
164 await server.users.getMyInfo()
165 })
166
167 it('Should have an expired access token', async function () {
168 this.timeout(60000)
169
170 await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
171 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
172
173 await killallServers([ server ])
174 await server.run()
175
176 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
177 })
178
179 it('Should not be able to refresh an access token with an expired refresh token', async function () {
180 await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
181 })
182
183 it('Should refresh the token', async function () {
184 this.timeout(50000)
185
186 const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
187 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
188
189 await killallServers([ server ])
190 await server.run()
191
192 const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
193 server.accessToken = res.body.access_token
194 server.refreshToken = res.body.refresh_token
195 })
196
197 it('Should be able to get my user information again', async function () {
198 await server.users.getMyInfo()
199 })
200 })
201
202 describe('Creating a user', function () { 35 describe('Creating a user', function () {
203 36
204 it('Should be able to create a new user', async function () { 37 it('Should be able to create a new user', async function () {
@@ -512,6 +345,7 @@ describe('Test users', function () {
512 }) 345 })
513 346
514 describe('Updating another user', function () { 347 describe('Updating another user', function () {
348
515 it('Should be able to update another user', async function () { 349 it('Should be able to update another user', async function () {
516 await server.users.update({ 350 await server.users.update({
517 userId, 351 userId,
@@ -562,13 +396,6 @@ describe('Test users', function () {
562 }) 396 })
563 }) 397 })
564 398
565 describe('Video blacklists', function () {
566
567 it('Should be able to list my video blacklist', async function () {
568 await server.blacklist.list({ token: userToken })
569 })
570 })
571
572 describe('Remove a user', function () { 399 describe('Remove a user', function () {
573 400
574 before(async function () { 401 before(async function () {
@@ -653,8 +480,9 @@ describe('Test users', function () {
653 }) 480 })
654 481
655 describe('User blocking', function () { 482 describe('User blocking', function () {
656 let user16Id 483 let user16Id: number
657 let user16AccessToken 484 let user16AccessToken: string
485
658 const user16 = { 486 const user16 = {
659 username: 'user_16', 487 username: 'user_16',
660 password: 'my super password' 488 password: 'my super password'
diff --git a/server/tests/api/videos/video-channel-syncs.ts b/server/tests/api/videos/video-channel-syncs.ts
index 91291524d..dd483f95e 100644
--- a/server/tests/api/videos/video-channel-syncs.ts
+++ b/server/tests/api/videos/video-channel-syncs.ts
@@ -307,6 +307,7 @@ describe('Test channel synchronizations', function () {
307 }) 307 })
308 } 308 }
309 309
310 runSuite('youtube-dl') 310 // FIXME: suite is broken with youtube-dl
311 // runSuite('youtube-dl')
311 runSuite('yt-dlp') 312 runSuite('yt-dlp')
312}) 313})
diff --git a/server/tests/api/videos/video-comments.ts b/server/tests/api/videos/video-comments.ts
index dc47f8a4a..e077cbf73 100644
--- a/server/tests/api/videos/video-comments.ts
+++ b/server/tests/api/videos/video-comments.ts
@@ -38,6 +38,8 @@ describe('Test video comments', function () {
38 await setDefaultAccountAvatar(server) 38 await setDefaultAccountAvatar(server)
39 39
40 userAccessTokenServer1 = await server.users.generateUserAndToken('user1') 40 userAccessTokenServer1 = await server.users.generateUserAndToken('user1')
41 await setDefaultChannelAvatar(server, 'user1_channel')
42 await setDefaultAccountAvatar(server, userAccessTokenServer1)
41 43
42 command = server.comments 44 command = server.comments
43 }) 45 })
@@ -232,16 +234,34 @@ describe('Test video comments', function () {
232 await command.addReply({ videoId, toCommentId: threadId2, text: text3 }) 234 await command.addReply({ videoId, toCommentId: threadId2, text: text3 })
233 235
234 const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 }) 236 const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 })
235 expect(tree.comment.totalReplies).to.equal(tree.comment.totalRepliesFromVideoAuthor + 1) 237 expect(tree.comment.totalRepliesFromVideoAuthor).to.equal(1)
238 expect(tree.comment.totalReplies).to.equal(2)
236 }) 239 })
237 }) 240 })
238 241
239 describe('All instance comments', function () { 242 describe('All instance comments', function () {
240 243
241 it('Should list instance comments as admin', async function () { 244 it('Should list instance comments as admin', async function () {
242 const { data } = await command.listForAdmin({ start: 0, count: 1 }) 245 {
246 const { data, total } = await command.listForAdmin({ start: 0, count: 1 })
243 247
244 expect(data[0].text).to.equal('my second answer to thread 4') 248 expect(total).to.equal(7)
249 expect(data).to.have.lengthOf(1)
250 expect(data[0].text).to.equal('my second answer to thread 4')
251 expect(data[0].account.name).to.equal('root')
252 expect(data[0].account.displayName).to.equal('root')
253 expect(data[0].account.avatars).to.have.lengthOf(2)
254 }
255
256 {
257 const { data, total } = await command.listForAdmin({ start: 1, count: 2 })
258
259 expect(total).to.equal(7)
260 expect(data).to.have.lengthOf(2)
261
262 expect(data[0].account.avatars).to.have.lengthOf(2)
263 expect(data[1].account.avatars).to.have.lengthOf(2)
264 }
245 }) 265 })
246 266
247 it('Should filter instance comments by isLocal', async function () { 267 it('Should filter instance comments by isLocal', async function () {
diff --git a/server/tests/external-plugins/auto-block-videos.ts b/server/tests/external-plugins/auto-block-videos.ts
index d14587c38..cadd02e8d 100644
--- a/server/tests/external-plugins/auto-block-videos.ts
+++ b/server/tests/external-plugins/auto-block-videos.ts
@@ -30,7 +30,7 @@ describe('Official plugin auto-block videos', function () {
30 let port: number 30 let port: number
31 31
32 before(async function () { 32 before(async function () {
33 this.timeout(60000) 33 this.timeout(120000)
34 34
35 servers = await createMultipleServers(2) 35 servers = await createMultipleServers(2)
36 await setAccessTokensToServers(servers) 36 await setAccessTokensToServers(servers)
diff --git a/server/tests/external-plugins/auto-mute.ts b/server/tests/external-plugins/auto-mute.ts
index 440b58bfd..cfed76e88 100644
--- a/server/tests/external-plugins/auto-mute.ts
+++ b/server/tests/external-plugins/auto-mute.ts
@@ -21,7 +21,7 @@ describe('Official plugin auto-mute', function () {
21 let port: number 21 let port: number
22 22
23 before(async function () { 23 before(async function () {
24 this.timeout(30000) 24 this.timeout(120000)
25 25
26 servers = await createMultipleServers(2) 26 servers = await createMultipleServers(2)
27 await setAccessTokensToServers(servers) 27 await setAccessTokensToServers(servers)
diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts
index 906dab1a3..7345f728a 100644
--- a/server/tests/feeds/feeds.ts
+++ b/server/tests/feeds/feeds.ts
@@ -189,7 +189,7 @@ describe('Test syndication feeds', () => {
189 const jsonObj = JSON.parse(json) 189 const jsonObj = JSON.parse(json)
190 expect(jsonObj.items.length).to.be.equal(1) 190 expect(jsonObj.items.length).to.be.equal(1)
191 expect(jsonObj.items[0].title).to.equal('my super name for server 1') 191 expect(jsonObj.items[0].title).to.equal('my super name for server 1')
192 expect(jsonObj.items[0].author.name).to.equal('root') 192 expect(jsonObj.items[0].author.name).to.equal('Main root channel')
193 } 193 }
194 194
195 { 195 {
@@ -197,7 +197,7 @@ describe('Test syndication feeds', () => {
197 const jsonObj = JSON.parse(json) 197 const jsonObj = JSON.parse(json)
198 expect(jsonObj.items.length).to.be.equal(1) 198 expect(jsonObj.items.length).to.be.equal(1)
199 expect(jsonObj.items[0].title).to.equal('user video') 199 expect(jsonObj.items[0].title).to.equal('user video')
200 expect(jsonObj.items[0].author.name).to.equal('john') 200 expect(jsonObj.items[0].author.name).to.equal('Main john channel')
201 } 201 }
202 202
203 for (const server of servers) { 203 for (const server of servers) {
@@ -223,7 +223,7 @@ describe('Test syndication feeds', () => {
223 const jsonObj = JSON.parse(json) 223 const jsonObj = JSON.parse(json)
224 expect(jsonObj.items.length).to.be.equal(1) 224 expect(jsonObj.items.length).to.be.equal(1)
225 expect(jsonObj.items[0].title).to.equal('my super name for server 1') 225 expect(jsonObj.items[0].title).to.equal('my super name for server 1')
226 expect(jsonObj.items[0].author.name).to.equal('root') 226 expect(jsonObj.items[0].author.name).to.equal('Main root channel')
227 } 227 }
228 228
229 { 229 {
@@ -231,7 +231,7 @@ describe('Test syndication feeds', () => {
231 const jsonObj = JSON.parse(json) 231 const jsonObj = JSON.parse(json)
232 expect(jsonObj.items.length).to.be.equal(1) 232 expect(jsonObj.items.length).to.be.equal(1)
233 expect(jsonObj.items[0].title).to.equal('user video') 233 expect(jsonObj.items[0].title).to.equal('user video')
234 expect(jsonObj.items[0].author.name).to.equal('john') 234 expect(jsonObj.items[0].author.name).to.equal('Main john channel')
235 } 235 }
236 236
237 for (const server of servers) { 237 for (const server of servers) {
diff --git a/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js b/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js
index c65b8d3a8..58bc27661 100644
--- a/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js
+++ b/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js
@@ -33,7 +33,17 @@ async function register ({
33 username: 'kefka', 33 username: 'kefka',
34 email: 'kefka@example.com', 34 email: 'kefka@example.com',
35 role: 0, 35 role: 0,
36 displayName: 'Kefka Palazzo' 36 displayName: 'Kefka Palazzo',
37 adminFlags: 1,
38 videoQuota: 42000,
39 videoQuotaDaily: 42100,
40
41 // Always use new value except for videoQuotaDaily field
42 userUpdater: ({ fieldName, currentValue, newValue }) => {
43 if (fieldName === 'videoQuotaDaily') return currentValue
44
45 return newValue
46 }
37 }) 47 })
38 }, 48 },
39 hookTokenValidity: (options) => { 49 hookTokenValidity: (options) => {
diff --git a/server/tests/fixtures/peertube-plugin-test-four/main.js b/server/tests/fixtures/peertube-plugin-test-four/main.js
index 3e848c49e..b10177b45 100644
--- a/server/tests/fixtures/peertube-plugin-test-four/main.js
+++ b/server/tests/fixtures/peertube-plugin-test-four/main.js
@@ -76,6 +76,12 @@ async function register ({
76 return res.json({ serverConfig }) 76 return res.json({ serverConfig })
77 }) 77 })
78 78
79 router.get('/server-listening-config', async (req, res) => {
80 const config = await peertubeHelpers.config.getServerListeningConfig()
81
82 return res.json({ config })
83 })
84
79 router.get('/static-route', async (req, res) => { 85 router.get('/static-route', async (req, res) => {
80 const staticRoute = peertubeHelpers.plugin.getBaseStaticRoute() 86 const staticRoute = peertubeHelpers.plugin.getBaseStaticRoute()
81 87
diff --git a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js
index ceab7b60d..fad5abf60 100644
--- a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js
+++ b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js
@@ -33,7 +33,18 @@ async function register ({
33 if (body.id === 'laguna' && body.password === 'laguna password') { 33 if (body.id === 'laguna' && body.password === 'laguna password') {
34 return Promise.resolve({ 34 return Promise.resolve({
35 username: 'laguna', 35 username: 'laguna',
36 email: 'laguna@example.com' 36 email: 'laguna@example.com',
37 displayName: 'Laguna Loire',
38 adminFlags: 1,
39 videoQuota: 42000,
40 videoQuotaDaily: 42100,
41
42 // Always use new value except for videoQuotaDaily field
43 userUpdater: ({ fieldName, currentValue, newValue }) => {
44 if (fieldName === 'videoQuotaDaily') return currentValue
45
46 return newValue
47 }
37 }) 48 })
38 } 49 }
39 50
diff --git a/server/tests/fixtures/peertube-plugin-test/main.js b/server/tests/fixtures/peertube-plugin-test/main.js
index 19dccf26e..19ba9f278 100644
--- a/server/tests/fixtures/peertube-plugin-test/main.js
+++ b/server/tests/fixtures/peertube-plugin-test/main.js
@@ -250,7 +250,10 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
250 250
251 registerHook({ 251 registerHook({
252 target: 'filter:api.download.video.allowed.result', 252 target: 'filter:api.download.video.allowed.result',
253 handler: (result, params) => { 253 handler: async (result, params) => {
254 const loggedInUser = await peertubeHelpers.user.getAuthUser(params.res)
255 if (loggedInUser) return { allowed: true }
256
254 if (params && !params.streamingPlaylist && params.video.name.includes('bad file')) { 257 if (params && !params.streamingPlaylist && params.video.name.includes('bad file')) {
255 return { allowed: false, errorMessage: 'Cao Cao' } 258 return { allowed: false, errorMessage: 'Cao Cao' }
256 } 259 }
diff --git a/server/tests/plugins/external-auth.ts b/server/tests/plugins/external-auth.ts
index 437777e90..e600f958f 100644
--- a/server/tests/plugins/external-auth.ts
+++ b/server/tests/plugins/external-auth.ts
@@ -2,7 +2,7 @@
2 2
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { wait } from '@shared/core-utils' 4import { wait } from '@shared/core-utils'
5import { HttpStatusCode, UserRole } from '@shared/models' 5import { HttpStatusCode, UserAdminFlag, UserRole } from '@shared/models'
6import { 6import {
7 cleanupTests, 7 cleanupTests,
8 createSingleServer, 8 createSingleServer,
@@ -51,6 +51,7 @@ describe('Test external auth plugins', function () {
51 51
52 let kefkaAccessToken: string 52 let kefkaAccessToken: string
53 let kefkaRefreshToken: string 53 let kefkaRefreshToken: string
54 let kefkaId: number
54 55
55 let externalAuthToken: string 56 let externalAuthToken: string
56 57
@@ -156,6 +157,9 @@ describe('Test external auth plugins', function () {
156 expect(body.account.displayName).to.equal('cyan') 157 expect(body.account.displayName).to.equal('cyan')
157 expect(body.email).to.equal('cyan@example.com') 158 expect(body.email).to.equal('cyan@example.com')
158 expect(body.role.id).to.equal(UserRole.USER) 159 expect(body.role.id).to.equal(UserRole.USER)
160 expect(body.adminFlags).to.equal(UserAdminFlag.NONE)
161 expect(body.videoQuota).to.equal(5242880)
162 expect(body.videoQuotaDaily).to.equal(-1)
159 } 163 }
160 }) 164 })
161 165
@@ -178,6 +182,11 @@ describe('Test external auth plugins', function () {
178 expect(body.account.displayName).to.equal('Kefka Palazzo') 182 expect(body.account.displayName).to.equal('Kefka Palazzo')
179 expect(body.email).to.equal('kefka@example.com') 183 expect(body.email).to.equal('kefka@example.com')
180 expect(body.role.id).to.equal(UserRole.ADMINISTRATOR) 184 expect(body.role.id).to.equal(UserRole.ADMINISTRATOR)
185 expect(body.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST)
186 expect(body.videoQuota).to.equal(42000)
187 expect(body.videoQuotaDaily).to.equal(42100)
188
189 kefkaId = body.id
181 } 190 }
182 }) 191 })
183 192
@@ -240,6 +249,37 @@ describe('Test external auth plugins', function () {
240 expect(body.role.id).to.equal(UserRole.USER) 249 expect(body.role.id).to.equal(UserRole.USER)
241 }) 250 })
242 251
252 it('Should login Kefka and update the profile', async function () {
253 {
254 await server.users.update({ userId: kefkaId, videoQuota: 43000, videoQuotaDaily: 43100 })
255 await server.users.updateMe({ token: kefkaAccessToken, displayName: 'kefka updated' })
256
257 const body = await server.users.getMyInfo({ token: kefkaAccessToken })
258 expect(body.username).to.equal('kefka')
259 expect(body.account.displayName).to.equal('kefka updated')
260 expect(body.videoQuota).to.equal(43000)
261 expect(body.videoQuotaDaily).to.equal(43100)
262 }
263
264 {
265 const res = await loginExternal({
266 server,
267 npmName: 'test-external-auth-one',
268 authName: 'external-auth-2',
269 username: 'kefka'
270 })
271
272 kefkaAccessToken = res.access_token
273 kefkaRefreshToken = res.refresh_token
274
275 const body = await server.users.getMyInfo({ token: kefkaAccessToken })
276 expect(body.username).to.equal('kefka')
277 expect(body.account.displayName).to.equal('Kefka Palazzo')
278 expect(body.videoQuota).to.equal(42000)
279 expect(body.videoQuotaDaily).to.equal(43100)
280 }
281 })
282
243 it('Should not update an external auth email', async function () { 283 it('Should not update an external auth email', async function () {
244 await server.users.updateMe({ 284 await server.users.updateMe({
245 token: cyanAccessToken, 285 token: cyanAccessToken,
diff --git a/server/tests/plugins/filter-hooks.ts b/server/tests/plugins/filter-hooks.ts
index 083fd43ca..6724b3bf8 100644
--- a/server/tests/plugins/filter-hooks.ts
+++ b/server/tests/plugins/filter-hooks.ts
@@ -430,6 +430,7 @@ describe('Test plugin filter hooks', function () {
430 430
431 describe('Download hooks', function () { 431 describe('Download hooks', function () {
432 const downloadVideos: VideoDetails[] = [] 432 const downloadVideos: VideoDetails[] = []
433 let downloadVideo2Token: string
433 434
434 before(async function () { 435 before(async function () {
435 this.timeout(120000) 436 this.timeout(120000)
@@ -459,6 +460,8 @@ describe('Test plugin filter hooks', function () {
459 for (const uuid of uuids) { 460 for (const uuid of uuids) {
460 downloadVideos.push(await servers[0].videos.get({ id: uuid })) 461 downloadVideos.push(await servers[0].videos.get({ id: uuid }))
461 } 462 }
463
464 downloadVideo2Token = await servers[0].videoToken.getVideoFileToken({ videoId: downloadVideos[2].uuid })
462 }) 465 })
463 466
464 it('Should run filter:api.download.torrent.allowed.result', async function () { 467 it('Should run filter:api.download.torrent.allowed.result', async function () {
@@ -471,32 +474,42 @@ describe('Test plugin filter hooks', function () {
471 474
472 it('Should run filter:api.download.video.allowed.result', async function () { 475 it('Should run filter:api.download.video.allowed.result', async function () {
473 { 476 {
474 const res = await makeRawRequest({ url: downloadVideos[1].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 477 const refused = downloadVideos[1].files[0].fileDownloadUrl
478 const allowed = [
479 downloadVideos[0].files[0].fileDownloadUrl,
480 downloadVideos[2].files[0].fileDownloadUrl
481 ]
482
483 const res = await makeRawRequest({ url: refused, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
475 expect(res.body.error).to.equal('Cao Cao') 484 expect(res.body.error).to.equal('Cao Cao')
476 485
477 await makeRawRequest({ url: downloadVideos[0].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) 486 for (const url of allowed) {
478 await makeRawRequest({ url: downloadVideos[2].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) 487 await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
488 await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
489 }
479 } 490 }
480 491
481 { 492 {
482 const res = await makeRawRequest({ 493 const refused = downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl
483 url: downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl,
484 expectedStatus: HttpStatusCode.FORBIDDEN_403
485 })
486 494
487 expect(res.body.error).to.equal('Sun Jian') 495 const allowed = [
496 downloadVideos[2].files[0].fileDownloadUrl,
497 downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl,
498 downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl
499 ]
488 500
489 await makeRawRequest({ url: downloadVideos[2].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) 501 // Only streaming playlist is refuse
502 const res = await makeRawRequest({ url: refused, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
503 expect(res.body.error).to.equal('Sun Jian')
490 504
491 await makeRawRequest({ 505 // But not we there is a user in res
492 url: downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl, 506 await makeRawRequest({ url: refused, token: servers[0].accessToken, expectedStatus: HttpStatusCode.OK_200 })
493 expectedStatus: HttpStatusCode.OK_200 507 await makeRawRequest({ url: refused, query: { videoFileToken: downloadVideo2Token }, expectedStatus: HttpStatusCode.OK_200 })
494 })
495 508
496 await makeRawRequest({ 509 // Other files work
497 url: downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl, 510 for (const url of allowed) {
498 expectedStatus: HttpStatusCode.OK_200 511 await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
499 }) 512 }
500 } 513 }
501 }) 514 })
502 }) 515 })
diff --git a/server/tests/plugins/id-and-pass-auth.ts b/server/tests/plugins/id-and-pass-auth.ts
index fc24a5656..10155c28b 100644
--- a/server/tests/plugins/id-and-pass-auth.ts
+++ b/server/tests/plugins/id-and-pass-auth.ts
@@ -13,6 +13,7 @@ describe('Test id and pass auth plugins', function () {
13 13
14 let lagunaAccessToken: string 14 let lagunaAccessToken: string
15 let lagunaRefreshToken: string 15 let lagunaRefreshToken: string
16 let lagunaId: number
16 17
17 before(async function () { 18 before(async function () {
18 this.timeout(30000) 19 this.timeout(30000)
@@ -78,8 +79,10 @@ describe('Test id and pass auth plugins', function () {
78 const body = await server.users.getMyInfo({ token: lagunaAccessToken }) 79 const body = await server.users.getMyInfo({ token: lagunaAccessToken })
79 80
80 expect(body.username).to.equal('laguna') 81 expect(body.username).to.equal('laguna')
81 expect(body.account.displayName).to.equal('laguna') 82 expect(body.account.displayName).to.equal('Laguna Loire')
82 expect(body.role.id).to.equal(UserRole.USER) 83 expect(body.role.id).to.equal(UserRole.USER)
84
85 lagunaId = body.id
83 } 86 }
84 }) 87 })
85 88
@@ -132,6 +135,33 @@ describe('Test id and pass auth plugins', function () {
132 expect(body.role.id).to.equal(UserRole.MODERATOR) 135 expect(body.role.id).to.equal(UserRole.MODERATOR)
133 }) 136 })
134 137
138 it('Should login Laguna and update the profile', async function () {
139 {
140 await server.users.update({ userId: lagunaId, videoQuota: 43000, videoQuotaDaily: 43100 })
141 await server.users.updateMe({ token: lagunaAccessToken, displayName: 'laguna updated' })
142
143 const body = await server.users.getMyInfo({ token: lagunaAccessToken })
144 expect(body.username).to.equal('laguna')
145 expect(body.account.displayName).to.equal('laguna updated')
146 expect(body.videoQuota).to.equal(43000)
147 expect(body.videoQuotaDaily).to.equal(43100)
148 }
149
150 {
151 const body = await server.login.login({ user: { username: 'laguna', password: 'laguna password' } })
152 lagunaAccessToken = body.access_token
153 lagunaRefreshToken = body.refresh_token
154 }
155
156 {
157 const body = await server.users.getMyInfo({ token: lagunaAccessToken })
158 expect(body.username).to.equal('laguna')
159 expect(body.account.displayName).to.equal('Laguna Loire')
160 expect(body.videoQuota).to.equal(42000)
161 expect(body.videoQuotaDaily).to.equal(43100)
162 }
163 })
164
135 it('Should reject token of laguna by the plugin hook', async function () { 165 it('Should reject token of laguna by the plugin hook', async function () {
136 this.timeout(10000) 166 this.timeout(10000)
137 167
@@ -147,7 +177,7 @@ describe('Test id and pass auth plugins', function () {
147 await server.servers.waitUntilLog('valid username') 177 await server.servers.waitUntilLog('valid username')
148 178
149 await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 179 await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
150 await server.servers.waitUntilLog('valid display name') 180 await server.servers.waitUntilLog('valid displayName')
151 181
152 await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 182 await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
153 await server.servers.waitUntilLog('valid role') 183 await server.servers.waitUntilLog('valid role')
diff --git a/server/tests/plugins/plugin-helpers.ts b/server/tests/plugins/plugin-helpers.ts
index 038e3f0d6..e25992723 100644
--- a/server/tests/plugins/plugin-helpers.ts
+++ b/server/tests/plugins/plugin-helpers.ts
@@ -64,6 +64,18 @@ describe('Test plugin helpers', function () {
64 await servers[0].servers.waitUntilLog(`server url is ${servers[0].url}`) 64 await servers[0].servers.waitUntilLog(`server url is ${servers[0].url}`)
65 }) 65 })
66 66
67 it('Should have the correct listening config', async function () {
68 const res = await makeGetRequest({
69 url: servers[0].url,
70 path: '/plugins/test-four/router/server-listening-config',
71 expectedStatus: HttpStatusCode.OK_200
72 })
73
74 expect(res.body.config).to.exist
75 expect(res.body.config.hostname).to.equal('::')
76 expect(res.body.config.port).to.equal(servers[0].port)
77 })
78
67 it('Should have the correct config', async function () { 79 it('Should have the correct config', async function () {
68 const res = await makeGetRequest({ 80 const res = await makeGetRequest({
69 url: servers[0].url, 81 url: servers[0].url,
diff --git a/server/types/express.d.ts b/server/types/express.d.ts
index 3738ffc47..6fea4dac2 100644
--- a/server/types/express.d.ts
+++ b/server/types/express.d.ts
@@ -1,4 +1,3 @@
1
2import { OutgoingHttpHeaders } from 'http' 1import { OutgoingHttpHeaders } from 'http'
3import { RegisterServerAuthExternalOptions } from '@server/types' 2import { RegisterServerAuthExternalOptions } from '@server/types'
4import { 3import {
@@ -10,6 +9,7 @@ import {
10 MChannelBannerAccountDefault, 9 MChannelBannerAccountDefault,
11 MChannelSyncChannel, 10 MChannelSyncChannel,
12 MStreamingPlaylist, 11 MStreamingPlaylist,
12 MUserAccountUrl,
13 MVideoChangeOwnershipFull, 13 MVideoChangeOwnershipFull,
14 MVideoFile, 14 MVideoFile,
15 MVideoFormattableDetails, 15 MVideoFormattableDetails,
@@ -187,6 +187,10 @@ declare module 'express' {
187 actor: MActorAccountChannelId 187 actor: MActorAccountChannelId
188 } 188 }
189 189
190 videoFileToken?: {
191 user: MUserAccountUrl
192 }
193
190 authenticated?: boolean 194 authenticated?: boolean
191 195
192 registeredPlugin?: RegisteredPlugin 196 registeredPlugin?: RegisteredPlugin
diff --git a/server/types/lib.d.ts b/server/types/lib.d.ts
new file mode 100644
index 000000000..c901e2032
--- /dev/null
+++ b/server/types/lib.d.ts
@@ -0,0 +1,12 @@
1type ObjectKeys<T> =
2 T extends object
3 ? `${Exclude<keyof T, symbol>}`[]
4 : T extends number
5 ? []
6 : T extends any | string
7 ? string[]
8 : never
9
10interface ObjectConstructor {
11 keys<T> (o: T): ObjectKeys<T>
12}
diff --git a/server/types/plugins/register-server-auth.model.ts b/server/types/plugins/register-server-auth.model.ts
index 79c18c406..e10968c20 100644
--- a/server/types/plugins/register-server-auth.model.ts
+++ b/server/types/plugins/register-server-auth.model.ts
@@ -1,14 +1,33 @@
1import express from 'express' 1import express from 'express'
2import { UserRole } from '@shared/models' 2import { UserAdminFlag, UserRole } from '@shared/models'
3import { MOAuthToken, MUser } from '../models' 3import { MOAuthToken, MUser } from '../models'
4 4
5export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions 5export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions
6 6
7export type AuthenticatedResultUpdaterFieldName = 'displayName' | 'role' | 'adminFlags' | 'videoQuota' | 'videoQuotaDaily'
8
7export interface RegisterServerAuthenticatedResult { 9export interface RegisterServerAuthenticatedResult {
10 // Update the user profile if it already exists
11 // Default behaviour is no update
12 // Introduced in PeerTube >= 5.1
13 userUpdater?: <T> (options: {
14 fieldName: AuthenticatedResultUpdaterFieldName
15 currentValue: T
16 newValue: T
17 }) => T
18
8 username: string 19 username: string
9 email: string 20 email: string
10 role?: UserRole 21 role?: UserRole
11 displayName?: string 22 displayName?: string
23
24 // PeerTube >= 5.1
25 adminFlags?: UserAdminFlag
26
27 // PeerTube >= 5.1
28 videoQuota?: number
29 // PeerTube >= 5.1
30 videoQuotaDaily?: number
12} 31}
13 32
14export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult { 33export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult {
diff --git a/server/types/plugins/register-server-option.model.ts b/server/types/plugins/register-server-option.model.ts
index 1e2bd830e..df419fff4 100644
--- a/server/types/plugins/register-server-option.model.ts
+++ b/server/types/plugins/register-server-option.model.ts
@@ -71,6 +71,9 @@ export type PeerTubeHelpers = {
71 config: { 71 config: {
72 getWebserverUrl: () => string 72 getWebserverUrl: () => string
73 73
74 // PeerTube >= 5.1
75 getServerListeningConfig: () => { hostname: string, port: number }
76
74 getServerConfig: () => Promise<ServerConfig> 77 getServerConfig: () => Promise<ServerConfig>
75 } 78 }
76 79