diff options
-rw-r--r-- | server/controllers/activitypub/client.ts | 2 | ||||
-rw-r--r-- | server/controllers/api/videos/comment.ts | 7 | ||||
-rw-r--r-- | server/lib/video-comment.ts | 33 | ||||
-rw-r--r-- | server/models/shared/model-builder.ts | 27 | ||||
-rw-r--r-- | server/models/utils.ts | 6 | ||||
-rw-r--r-- | server/models/video/sql/comment/video-comment-list-query-builder.ts | 394 | ||||
-rw-r--r-- | server/models/video/sql/comment/video-comment-table-attributes.ts | 78 | ||||
-rw-r--r-- | server/models/video/video-comment.ts | 446 | ||||
-rw-r--r-- | server/tests/api/videos/video-comments.ts | 3 |
9 files changed, 621 insertions, 375 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 @@ | |||
1 | import { MCommentFormattable } from '@server/types/models' | ||
1 | import express from 'express' | 2 | import express from 'express' |
3 | |||
2 | import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models' | 4 | import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models' |
3 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | 5 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' |
4 | import { VideoCommentThreads } from '../../../../shared/models/videos/comment/video-comment.model' | 6 | import { 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/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 @@ | |||
1 | import express from 'express' | ||
1 | import { cloneDeep } from 'lodash' | 2 | import { cloneDeep } from 'lodash' |
2 | import * as Sequelize from 'sequelize' | 3 | import * as Sequelize from 'sequelize' |
3 | import express from 'express' | ||
4 | import { logger } from '@server/helpers/logger' | 4 | import { logger } from '@server/helpers/logger' |
5 | import { sequelizeTypescript } from '@server/initializers/database' | 5 | import { sequelizeTypescript } from '@server/initializers/database' |
6 | import { ResultList } from '../../shared/models' | 6 | import { ResultList } from '../../shared/models' |
7 | import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model' | 7 | import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model' |
8 | import { VideoCommentModel } from '../models/video/video-comment' | 8 | import { VideoCommentModel } from '../models/video/video-comment' |
9 | import { MAccountDefault, MComment, MCommentOwnerVideo, MCommentOwnerVideoReply, MVideoFullLight } from '../types/models' | 9 | import { |
10 | MAccountDefault, | ||
11 | MComment, | ||
12 | MCommentFormattable, | ||
13 | MCommentOwnerVideo, | ||
14 | MCommentOwnerVideoReply, | ||
15 | MVideoFullLight | ||
16 | } from '../types/models' | ||
10 | import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send' | 17 | import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send' |
11 | import { getLocalVideoCommentActivityPubUrl } from './activitypub/url' | 18 | import { getLocalVideoCommentActivityPubUrl } from './activitypub/url' |
12 | import { Hooks } from './plugins/hooks' | 19 | import { Hooks } from './plugins/hooks' |
13 | 20 | ||
14 | async function removeComment (videoCommentInstance: MCommentOwnerVideo, req: express.Request, res: express.Response) { | 21 | async 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 | ||
67 | function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>): VideoCommentThreadTree { | 78 | function 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/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 @@ | |||
1 | import { isPlainObject } from 'lodash' | 1 | import { isPlainObject } from 'lodash' |
2 | import { Model as SequelizeModel, Sequelize } from 'sequelize' | 2 | import { Model as SequelizeModel, ModelStatic, Sequelize } from 'sequelize' |
3 | import { logger } from '@server/helpers/logger' | 3 | import { 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 | |||
5 | export class ModelBuilder <T extends SequelizeModel> { | 22 | export 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/utils.ts b/server/models/utils.ts index 3476799ce..0b6ac8340 100644 --- a/server/models/utils.ts +++ b/server/models/utils.ts | |||
@@ -231,12 +231,12 @@ function parseRowCountResult (result: any) { | |||
231 | return 0 | 231 | return 0 |
232 | } | 232 | } |
233 | 233 | ||
234 | function createSafeIn (sequelize: Sequelize, stringArr: (string | number)[]) { | 234 | function createSafeIn (sequelize: Sequelize, toEscape: (string | number)[], additionalUnescaped: string[] = []) { |
235 | return stringArr.map(t => { | 235 | return toEscape.map(t => { |
236 | return t === null | 236 | return t === null |
237 | ? null | 237 | ? null |
238 | : sequelize.escape('' + t) | 238 | : sequelize.escape('' + t) |
239 | }).join(', ') | 239 | }).concat(additionalUnescaped).join(', ') |
240 | } | 240 | } |
241 | 241 | ||
242 | function buildLocalAccountIdsIn () { | 242 | function buildLocalAccountIdsIn () { |
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..f3f6910e1 --- /dev/null +++ b/server/models/video/sql/comment/video-comment-list-query-builder.ts | |||
@@ -0,0 +1,394 @@ | |||
1 | import { Model, Sequelize, Transaction } from 'sequelize' | ||
2 | import { AbstractRunQuery, ModelBuilder } from '@server/models/shared' | ||
3 | import { createSafeIn, getCommentSort, parseRowCountResult } from '@server/models/utils' | ||
4 | import { ActorImageType, VideoPrivacy } from '@shared/models' | ||
5 | import { VideoCommentTableAttributes } from './video-comment-table-attributes' | ||
6 | |||
7 | export 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 | |||
37 | export 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 | `${this.getLimit()}` | ||
92 | } | ||
93 | |||
94 | private buildInnerListQuery () { | ||
95 | this.buildWhere() | ||
96 | this.buildInnerListSelect() | ||
97 | |||
98 | this.innerQuery = `${this.innerSelect} ` + | ||
99 | `FROM "videoComment" AS "VideoCommentModel" ` + | ||
100 | `${this.innerJoins} ` + | ||
101 | `${this.innerWhere} ` + | ||
102 | `${this.getOrder()} ` + | ||
103 | `${this.getInnerLimit()}` | ||
104 | } | ||
105 | |||
106 | // --------------------------------------------------------------------------- | ||
107 | |||
108 | private buildCountQuery () { | ||
109 | this.buildWhere() | ||
110 | |||
111 | this.query = `SELECT COUNT(*) AS "total" ` + | ||
112 | `FROM "videoComment" AS "VideoCommentModel" ` + | ||
113 | `${this.innerJoins} ` + | ||
114 | `${this.innerWhere}` | ||
115 | } | ||
116 | |||
117 | // --------------------------------------------------------------------------- | ||
118 | |||
119 | private buildWhere () { | ||
120 | let where: string[] = [] | ||
121 | |||
122 | if (this.options.videoId) { | ||
123 | this.replacements.videoId = this.options.videoId | ||
124 | |||
125 | where.push('"VideoCommentModel"."videoId" = :videoId') | ||
126 | } | ||
127 | |||
128 | if (this.options.threadId) { | ||
129 | this.replacements.threadId = this.options.threadId | ||
130 | |||
131 | where.push('("VideoCommentModel"."id" = :threadId OR "VideoCommentModel"."originCommentId" = :threadId)') | ||
132 | } | ||
133 | |||
134 | if (this.options.accountId) { | ||
135 | this.replacements.accountId = this.options.accountId | ||
136 | |||
137 | where.push('"VideoCommentModel"."accountId" = :accountId') | ||
138 | } | ||
139 | |||
140 | if (this.options.videoChannelId) { | ||
141 | this.buildVideoChannelJoin() | ||
142 | |||
143 | this.replacements.videoChannelId = this.options.videoChannelId | ||
144 | |||
145 | where.push('"Account->VideoChannel"."id" = :videoChannelId') | ||
146 | } | ||
147 | |||
148 | if (this.options.blockerAccountIds) { | ||
149 | this.buildVideoChannelJoin() | ||
150 | |||
151 | where = where.concat(this.getBlockWhere('VideoCommentModel', 'Video->VideoChannel')) | ||
152 | } | ||
153 | |||
154 | if (this.options.isThread === true) { | ||
155 | where.push('"VideoCommentModel"."inReplyToCommentId" IS NULL') | ||
156 | } | ||
157 | |||
158 | if (this.options.notDeleted === true) { | ||
159 | where.push('"VideoCommentModel"."deletedAt" IS NULL') | ||
160 | } | ||
161 | |||
162 | if (this.options.isLocal === true) { | ||
163 | this.buildAccountJoin() | ||
164 | |||
165 | where.push('"Account->Actor"."serverId" IS NULL') | ||
166 | } else if (this.options.isLocal === false) { | ||
167 | this.buildAccountJoin() | ||
168 | |||
169 | where.push('"Account->Actor"."serverId" IS NOT NULL') | ||
170 | } | ||
171 | |||
172 | if (this.options.onLocalVideo === true) { | ||
173 | this.buildVideoJoin() | ||
174 | |||
175 | where.push('"Video"."remote" IS FALSE') | ||
176 | } else if (this.options.onLocalVideo === false) { | ||
177 | this.buildVideoJoin() | ||
178 | |||
179 | where.push('"Video"."remote" IS TRUE') | ||
180 | } | ||
181 | |||
182 | if (this.options.onPublicVideo === true) { | ||
183 | this.buildVideoJoin() | ||
184 | |||
185 | where.push(`"Video"."privacy" = ${VideoPrivacy.PUBLIC}`) | ||
186 | } | ||
187 | |||
188 | if (this.options.videoAccountOwnerId) { | ||
189 | this.buildVideoChannelJoin() | ||
190 | |||
191 | this.replacements.videoAccountOwnerId = this.options.videoAccountOwnerId | ||
192 | |||
193 | where.push(`"Video->VideoChannel"."accountId" = :videoAccountOwnerId`) | ||
194 | } | ||
195 | |||
196 | if (this.options.search) { | ||
197 | this.buildVideoJoin() | ||
198 | this.buildAccountJoin() | ||
199 | |||
200 | const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%') | ||
201 | |||
202 | where.push( | ||
203 | `(` + | ||
204 | `"VideoCommentModel"."text" ILIKE ${escapedLikeSearch} OR ` + | ||
205 | `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` + | ||
206 | `"Account"."name" ILIKE ${escapedLikeSearch} OR ` + | ||
207 | `"Video"."name" ILIKE ${escapedLikeSearch} ` + | ||
208 | `)` | ||
209 | ) | ||
210 | } | ||
211 | |||
212 | if (this.options.searchAccount) { | ||
213 | this.buildAccountJoin() | ||
214 | |||
215 | const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchAccount + '%') | ||
216 | |||
217 | where.push( | ||
218 | `(` + | ||
219 | `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` + | ||
220 | `"Account"."name" ILIKE ${escapedLikeSearch} ` + | ||
221 | `)` | ||
222 | ) | ||
223 | } | ||
224 | |||
225 | if (this.options.searchVideo) { | ||
226 | this.buildVideoJoin() | ||
227 | |||
228 | const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchVideo + '%') | ||
229 | |||
230 | where.push(`"Video"."name" ILIKE ${escapedLikeSearch}`) | ||
231 | } | ||
232 | |||
233 | if (where.length !== 0) { | ||
234 | this.innerWhere = `WHERE ${where.join(' AND ')}` | ||
235 | } | ||
236 | } | ||
237 | |||
238 | private buildAccountJoin () { | ||
239 | if (this.built.accountJoin) return | ||
240 | |||
241 | this.innerJoins += ' LEFT JOIN "account" "Account" ON "Account"."id" = "VideoCommentModel"."accountId" ' + | ||
242 | 'LEFT JOIN "actor" "Account->Actor" ON "Account->Actor"."id" = "Account"."actorId" ' + | ||
243 | 'LEFT JOIN "server" "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" ' | ||
244 | |||
245 | this.built.accountJoin = true | ||
246 | } | ||
247 | |||
248 | private buildVideoJoin () { | ||
249 | if (this.built.videoJoin) return | ||
250 | |||
251 | this.innerJoins += ' LEFT JOIN "video" "Video" ON "Video"."id" = "VideoCommentModel"."videoId" ' | ||
252 | |||
253 | this.built.videoJoin = true | ||
254 | } | ||
255 | |||
256 | private buildVideoChannelJoin () { | ||
257 | if (this.built.videoChannelJoin) return | ||
258 | |||
259 | this.buildVideoJoin() | ||
260 | |||
261 | this.innerJoins += ' LEFT JOIN "videoChannel" "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id" ' | ||
262 | |||
263 | this.built.videoChannelJoin = true | ||
264 | } | ||
265 | |||
266 | private buildAvatarsJoin () { | ||
267 | if (this.options.selectType !== 'api' && this.options.selectType !== 'feed') return '' | ||
268 | if (this.built.avatarJoin) return | ||
269 | |||
270 | this.joins += `LEFT JOIN "actorImage" "Account->Actor->Avatars" ` + | ||
271 | `ON "VideoCommentModel"."Account.Actor.id" = "Account->Actor->Avatars"."actorId" ` + | ||
272 | `AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}` | ||
273 | |||
274 | this.built.avatarJoin = true | ||
275 | } | ||
276 | |||
277 | // --------------------------------------------------------------------------- | ||
278 | |||
279 | private buildListSelect () { | ||
280 | const toSelect = [ '"VideoCommentModel".*' ] | ||
281 | |||
282 | if (this.options.selectType === 'api' || this.options.selectType === 'feed') { | ||
283 | this.buildAvatarsJoin() | ||
284 | |||
285 | toSelect.push(this.tableAttributes.getAvatarAttributes()) | ||
286 | } | ||
287 | |||
288 | if (this.options.includeReplyCounters === true) { | ||
289 | toSelect.push(this.getTotalRepliesSelect()) | ||
290 | toSelect.push(this.getAuthorTotalRepliesSelect()) | ||
291 | } | ||
292 | |||
293 | this.select = this.buildSelect(toSelect) | ||
294 | } | ||
295 | |||
296 | private buildInnerListSelect () { | ||
297 | let toSelect = [ this.tableAttributes.getVideoCommentAttributes() ] | ||
298 | |||
299 | if (this.options.selectType === 'api' || this.options.selectType === 'feed') { | ||
300 | this.buildAccountJoin() | ||
301 | this.buildVideoJoin() | ||
302 | |||
303 | toSelect = toSelect.concat([ | ||
304 | this.tableAttributes.getVideoAttributes(), | ||
305 | this.tableAttributes.getAccountAttributes(), | ||
306 | this.tableAttributes.getActorAttributes(), | ||
307 | this.tableAttributes.getServerAttributes() | ||
308 | ]) | ||
309 | } | ||
310 | |||
311 | this.innerSelect = this.buildSelect(toSelect) | ||
312 | } | ||
313 | |||
314 | // --------------------------------------------------------------------------- | ||
315 | |||
316 | private getBlockWhere (commentTableName: string, channelTableName: string) { | ||
317 | const where: string[] = [] | ||
318 | |||
319 | const blockerIdsString = createSafeIn( | ||
320 | this.sequelize, | ||
321 | this.options.blockerAccountIds, | ||
322 | [ `"${channelTableName}"."accountId"` ] | ||
323 | ) | ||
324 | |||
325 | where.push( | ||
326 | `NOT EXISTS (` + | ||
327 | `SELECT 1 FROM "accountBlocklist" ` + | ||
328 | `WHERE "targetAccountId" = "${commentTableName}"."accountId" ` + | ||
329 | `AND "accountId" IN (${blockerIdsString})` + | ||
330 | `)` | ||
331 | ) | ||
332 | |||
333 | where.push( | ||
334 | `NOT EXISTS (` + | ||
335 | `SELECT 1 FROM "account" ` + | ||
336 | `INNER JOIN "actor" ON account."actorId" = actor.id ` + | ||
337 | `INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` + | ||
338 | `WHERE "account"."id" = "${commentTableName}"."accountId" ` + | ||
339 | `AND "serverBlocklist"."accountId" IN (${blockerIdsString})` + | ||
340 | `)` | ||
341 | ) | ||
342 | |||
343 | return where | ||
344 | } | ||
345 | |||
346 | // --------------------------------------------------------------------------- | ||
347 | |||
348 | private getTotalRepliesSelect () { | ||
349 | const blockWhereString = this.getBlockWhere('replies', 'videoChannel').join(' AND ') | ||
350 | |||
351 | return `(` + | ||
352 | `SELECT COUNT("replies"."id") FROM "videoComment" AS "replies" ` + | ||
353 | `LEFT JOIN "video" ON "video"."id" = "replies"."videoId" ` + | ||
354 | `LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id" ` + | ||
355 | `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ` + | ||
356 | `AND "deletedAt" IS NULL ` + | ||
357 | `AND ${blockWhereString} ` + | ||
358 | `) AS "totalReplies"` | ||
359 | } | ||
360 | |||
361 | private getAuthorTotalRepliesSelect () { | ||
362 | return `(` + | ||
363 | `SELECT COUNT("replies"."id") FROM "videoComment" AS "replies" ` + | ||
364 | `INNER JOIN "video" ON "video"."id" = "replies"."videoId" ` + | ||
365 | `INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` + | ||
366 | `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" AND "replies"."accountId" = "videoChannel"."accountId"` + | ||
367 | `) AS "totalRepliesFromVideoAuthor"` | ||
368 | } | ||
369 | |||
370 | private getOrder () { | ||
371 | if (!this.options.sort) return '' | ||
372 | |||
373 | const orders = getCommentSort(this.options.sort) | ||
374 | |||
375 | return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ') | ||
376 | } | ||
377 | |||
378 | private getLimit () { | ||
379 | if (!this.options.count) return '' | ||
380 | |||
381 | this.replacements.limit = this.options.count | ||
382 | |||
383 | return `LIMIT :limit ` | ||
384 | } | ||
385 | |||
386 | private getInnerLimit () { | ||
387 | if (!this.options.count) return '' | ||
388 | |||
389 | this.replacements.limit = this.options.count | ||
390 | this.replacements.offset = this.options.start || 0 | ||
391 | |||
392 | return `LIMIT :limit OFFSET :offset ` | ||
393 | } | ||
394 | } | ||
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..cae3c1683 --- /dev/null +++ b/server/models/video/sql/comment/video-comment-table-attributes.ts | |||
@@ -0,0 +1,78 @@ | |||
1 | export class VideoCommentTableAttributes { | ||
2 | |||
3 | getVideoCommentAttributes () { | ||
4 | return [ | ||
5 | '"VideoCommentModel"."id"', | ||
6 | '"VideoCommentModel"."url"', | ||
7 | '"VideoCommentModel"."deletedAt"', | ||
8 | '"VideoCommentModel"."updatedAt"', | ||
9 | '"VideoCommentModel"."createdAt"', | ||
10 | '"VideoCommentModel"."text"', | ||
11 | '"VideoCommentModel"."originCommentId"', | ||
12 | '"VideoCommentModel"."inReplyToCommentId"', | ||
13 | '"VideoCommentModel"."videoId"', | ||
14 | '"VideoCommentModel"."accountId"' | ||
15 | ].join(', ') | ||
16 | } | ||
17 | |||
18 | getAccountAttributes () { | ||
19 | return [ | ||
20 | `"Account"."id" AS "Account.id"`, | ||
21 | `"Account"."name" AS "Account.name"`, | ||
22 | `"Account"."description" AS "Account.description"`, | ||
23 | `"Account"."createdAt" AS "Account.createdAt"`, | ||
24 | `"Account"."updatedAt" AS "Account.updatedAt"`, | ||
25 | `"Account"."actorId" AS "Account.actorId"`, | ||
26 | `"Account"."userId" AS "Account.userId"`, | ||
27 | `"Account"."applicationId" AS "Account.applicationId"` | ||
28 | ].join(', ') | ||
29 | } | ||
30 | |||
31 | getVideoAttributes () { | ||
32 | return [ | ||
33 | `"Video"."id" AS "Video.id"`, | ||
34 | `"Video"."uuid" AS "Video.uuid"`, | ||
35 | `"Video"."name" AS "Video.name"` | ||
36 | ].join(', ') | ||
37 | } | ||
38 | |||
39 | getActorAttributes () { | ||
40 | return [ | ||
41 | `"Account->Actor"."id" AS "Account.Actor.id"`, | ||
42 | `"Account->Actor"."type" AS "Account.Actor.type"`, | ||
43 | `"Account->Actor"."preferredUsername" AS "Account.Actor.preferredUsername"`, | ||
44 | `"Account->Actor"."url" AS "Account.Actor.url"`, | ||
45 | `"Account->Actor"."followersCount" AS "Account.Actor.followersCount"`, | ||
46 | `"Account->Actor"."followingCount" AS "Account.Actor.followingCount"`, | ||
47 | `"Account->Actor"."remoteCreatedAt" AS "Account.Actor.remoteCreatedAt"`, | ||
48 | `"Account->Actor"."serverId" AS "Account.Actor.serverId"`, | ||
49 | `"Account->Actor"."createdAt" AS "Account.Actor.createdAt"`, | ||
50 | `"Account->Actor"."updatedAt" AS "Account.Actor.updatedAt"` | ||
51 | ].join(', ') | ||
52 | } | ||
53 | |||
54 | getServerAttributes () { | ||
55 | return [ | ||
56 | `"Account->Actor->Server"."id" AS "Account.Actor.Server.id"`, | ||
57 | `"Account->Actor->Server"."host" AS "Account.Actor.Server.host"`, | ||
58 | `"Account->Actor->Server"."redundancyAllowed" AS "Account.Actor.Server.redundancyAllowed"`, | ||
59 | `"Account->Actor->Server"."createdAt" AS "Account.Actor.Server.createdAt"`, | ||
60 | `"Account->Actor->Server"."updatedAt" AS "Account.Actor.Server.updatedAt"` | ||
61 | ].join(', ') | ||
62 | } | ||
63 | |||
64 | getAvatarAttributes () { | ||
65 | return [ | ||
66 | `"Account->Actor->Avatars"."id" AS "Account.Actor.Avatars.id"`, | ||
67 | `"Account->Actor->Avatars"."filename" AS "Account.Actor.Avatars.filename"`, | ||
68 | `"Account->Actor->Avatars"."height" AS "Account.Actor.Avatars.height"`, | ||
69 | `"Account->Actor->Avatars"."width" AS "Account.Actor.Avatars.width"`, | ||
70 | `"Account->Actor->Avatars"."fileUrl" AS "Account.Actor.Avatars.fileUrl"`, | ||
71 | `"Account->Actor->Avatars"."onDisk" AS "Account.Actor.Avatars.onDisk"`, | ||
72 | `"Account->Actor->Avatars"."type" AS "Account.Actor.Avatars.type"`, | ||
73 | `"Account->Actor->Avatars"."actorId" AS "Account.Actor.Avatars.actorId"`, | ||
74 | `"Account->Actor->Avatars"."createdAt" AS "Account.Actor.Avatars.createdAt"`, | ||
75 | `"Account->Actor->Avatars"."updatedAt" AS "Account.Actor.Avatars.updatedAt"` | ||
76 | ].join(', ') | ||
77 | } | ||
78 | } | ||
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts index af9614d30..fb9d15e55 100644 --- a/server/models/video/video-comment.ts +++ b/server/models/video/video-comment.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { FindOptions, Op, Order, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' | 1 | import { FindOptions, Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize' |
2 | import { | 2 | import { |
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' |
16 | import { exists } from '@server/helpers/custom-validators/misc' | ||
17 | import { getServerActor } from '@server/models/application/application' | 16 | import { getServerActor } from '@server/models/application/application' |
18 | import { MAccount, MAccountId, MUserAccountId } from '@server/types/models' | 17 | import { MAccount, MAccountId, MUserAccountId } from '@server/types/models' |
19 | import { uniqify } from '@shared/core-utils' | 18 | import { pick, uniqify } from '@shared/core-utils' |
20 | import { VideoPrivacy } from '@shared/models' | ||
21 | import { AttributesOnly } from '@shared/typescript-utils' | 19 | import { AttributesOnly } from '@shared/typescript-utils' |
22 | import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' | 20 | import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' |
23 | import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' | 21 | import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' |
@@ -41,61 +39,19 @@ import { | |||
41 | } from '../../types/models/video' | 39 | } from '../../types/models/video' |
42 | import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse' | 40 | import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse' |
43 | import { AccountModel } from '../account/account' | 41 | import { AccountModel } from '../account/account' |
44 | import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor' | 42 | import { ActorModel } from '../actor/actor' |
45 | import { | 43 | import { buildLocalAccountIdsIn, throwIfNotValid } from '../utils' |
46 | buildBlockedAccountSQL, | 44 | import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder' |
47 | buildBlockedAccountSQLOptimized, | ||
48 | buildLocalAccountIdsIn, | ||
49 | getCommentSort, | ||
50 | searchAttribute, | ||
51 | throwIfNotValid | ||
52 | } from '../utils' | ||
53 | import { VideoModel } from './video' | 45 | import { VideoModel } from './video' |
54 | import { VideoChannelModel } from './video-channel' | 46 | import { VideoChannelModel } from './video-channel' |
55 | 47 | ||
56 | export enum ScopeNames { | 48 | export 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 | { |
@@ -319,93 +259,19 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment | |||
319 | searchAccount?: string | 259 | searchAccount?: string |
320 | searchVideo?: string | 260 | searchVideo?: string |
321 | }) { | 261 | }) { |
322 | const { start, count, sort, isLocal, search, searchAccount, searchVideo, onLocalVideo } = parameters | 262 | const queryOptions: ListVideoCommentsOptions = { |
323 | 263 | ...pick(parameters, [ 'start', 'count', 'sort', 'isLocal', 'search', 'searchVideo', 'searchAccount', 'onLocalVideo' ]), | |
324 | const where: WhereOptions = { | ||
325 | deletedAt: null | ||
326 | } | ||
327 | 264 | ||
328 | const whereAccount: WhereOptions = {} | 265 | selectType: 'api', |
329 | const whereActor: WhereOptions = {} | 266 | notDeleted: true |
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 | } | 267 | } |
404 | 268 | ||
405 | return Promise.all([ | 269 | return Promise.all([ |
406 | VideoCommentModel.count(getQuery(true)), | 270 | new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(), |
407 | VideoCommentModel.findAll(getQuery(false)) | 271 | new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments() |
408 | ]).then(([ total, data ]) => ({ total, data })) | 272 | ]).then(([ rows, count ]) => { |
273 | return { total: count, data: rows } | ||
274 | }) | ||
409 | } | 275 | } |
410 | 276 | ||
411 | static async listThreadsForApi (parameters: { | 277 | static async listThreadsForApi (parameters: { |
@@ -416,67 +282,40 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment | |||
416 | sort: string | 282 | sort: string |
417 | user?: MUserAccountId | 283 | user?: MUserAccountId |
418 | }) { | 284 | }) { |
419 | const { videoId, isVideoOwned, start, count, sort, user } = parameters | 285 | const { videoId, user } = parameters |
420 | 286 | ||
421 | const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned }) | 287 | const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user }) |
422 | 288 | ||
423 | const accountBlockedWhere = { | 289 | const commonOptions: ListVideoCommentsOptions = { |
424 | accountId: { | 290 | selectType: 'api', |
425 | [Op.notIn]: Sequelize.literal( | 291 | videoId, |
426 | '(' + buildBlockedAccountSQL(blockerAccountIds) + ')' | 292 | blockerAccountIds |
427 | ) | ||
428 | } | ||
429 | } | 293 | } |
430 | 294 | ||
431 | const queryList = { | 295 | const listOptions: ListVideoCommentsOptions = { |
432 | offset: start, | 296 | ...commonOptions, |
433 | limit: count, | 297 | ...pick(parameters, [ 'sort', 'start', 'count' ]), |
434 | order: getCommentSort(sort), | 298 | |
435 | where: { | 299 | isThread: true, |
436 | [Op.and]: [ | 300 | 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 | } | 301 | } |
454 | 302 | ||
455 | const findScopesList: (string | ScopeOptions)[] = [ | 303 | const countOptions: ListVideoCommentsOptions = { |
456 | ScopeNames.WITH_ACCOUNT_FOR_API, | 304 | ...commonOptions, |
457 | { | ||
458 | method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ] | ||
459 | } | ||
460 | ] | ||
461 | 305 | ||
462 | const countScopesList: ScopeOptions[] = [ | 306 | isThread: true |
463 | { | 307 | } |
464 | method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ] | ||
465 | } | ||
466 | ] | ||
467 | 308 | ||
468 | const notDeletedQueryCount = { | 309 | const notDeletedCountOptions: ListVideoCommentsOptions = { |
469 | where: { | 310 | ...commonOptions, |
470 | videoId, | 311 | |
471 | deletedAt: null, | 312 | notDeleted: true |
472 | ...accountBlockedWhere | ||
473 | } | ||
474 | } | 313 | } |
475 | 314 | ||
476 | return Promise.all([ | 315 | return Promise.all([ |
477 | VideoCommentModel.scope(findScopesList).findAll(queryList), | 316 | new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminFormattable>(), |
478 | VideoCommentModel.scope(countScopesList).count(queryList), | 317 | new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, countOptions).countComments(), |
479 | VideoCommentModel.count(notDeletedQueryCount) | 318 | new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, notDeletedCountOptions).countComments() |
480 | ]).then(([ rows, count, totalNotDeletedComments ]) => { | 319 | ]).then(([ rows, count, totalNotDeletedComments ]) => { |
481 | return { total: count, data: rows, totalNotDeletedComments } | 320 | return { total: count, data: rows, totalNotDeletedComments } |
482 | }) | 321 | }) |
@@ -484,54 +323,29 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment | |||
484 | 323 | ||
485 | static async listThreadCommentsForApi (parameters: { | 324 | static async listThreadCommentsForApi (parameters: { |
486 | videoId: number | 325 | videoId: number |
487 | isVideoOwned: boolean | ||
488 | threadId: number | 326 | threadId: number |
489 | user?: MUserAccountId | 327 | user?: MUserAccountId |
490 | }) { | 328 | }) { |
491 | const { videoId, threadId, user, isVideoOwned } = parameters | 329 | const { user } = parameters |
492 | 330 | ||
493 | const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned }) | 331 | const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user }) |
494 | 332 | ||
495 | const query = { | 333 | const queryOptions: ListVideoCommentsOptions = { |
496 | order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order, | 334 | ...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 | 335 | ||
524 | const scopes: any[] = [ | 336 | selectType: 'api', |
525 | ScopeNames.WITH_ACCOUNT_FOR_API, | 337 | sort: 'createdAt', |
526 | { | 338 | |
527 | method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ] | 339 | blockerAccountIds, |
528 | } | 340 | includeReplyCounters: true |
529 | ] | 341 | } |
530 | 342 | ||
531 | return Promise.all([ | 343 | return Promise.all([ |
532 | VideoCommentModel.count(query), | 344 | new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(), |
533 | VideoCommentModel.scope(scopes).findAll(query) | 345 | new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments() |
534 | ]).then(([ total, data ]) => ({ total, data })) | 346 | ]).then(([ rows, count ]) => { |
347 | return { total: count, data: rows } | ||
348 | }) | ||
535 | } | 349 | } |
536 | 350 | ||
537 | static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> { | 351 | static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> { |
@@ -559,31 +373,31 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment | |||
559 | .findAll(query) | 373 | .findAll(query) |
560 | } | 374 | } |
561 | 375 | ||
562 | static async listAndCountByVideoForAP (video: MVideoImmutable, start: number, count: number, t?: Transaction) { | 376 | static async listAndCountByVideoForAP (parameters: { |
563 | const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ | 377 | video: MVideoImmutable |
378 | start: number | ||
379 | count: number | ||
380 | }) { | ||
381 | const { video } = parameters | ||
382 | |||
383 | const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null }) | ||
384 | |||
385 | const queryOptions: ListVideoCommentsOptions = { | ||
386 | ...pick(parameters, [ 'start', 'count' ]), | ||
387 | |||
388 | selectType: 'comment-only', | ||
564 | videoId: video.id, | 389 | videoId: video.id, |
565 | isVideoOwned: video.isOwned() | 390 | sort: 'createdAt', |
566 | }) | ||
567 | 391 | ||
568 | const query = { | 392 | 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 | } | 393 | } |
582 | 394 | ||
583 | return Promise.all([ | 395 | return Promise.all([ |
584 | VideoCommentModel.count(query), | 396 | new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>(), |
585 | VideoCommentModel.findAll<MComment>(query) | 397 | new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments() |
586 | ]).then(([ total, data ]) => ({ total, data })) | 398 | ]).then(([ rows, count ]) => { |
399 | return { total: count, data: rows } | ||
400 | }) | ||
587 | } | 401 | } |
588 | 402 | ||
589 | static async listForFeed (parameters: { | 403 | static async listForFeed (parameters: { |
@@ -592,97 +406,36 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment | |||
592 | videoId?: number | 406 | videoId?: number |
593 | accountId?: number | 407 | accountId?: number |
594 | videoChannelId?: number | 408 | videoChannelId?: number |
595 | }): Promise<MCommentOwnerVideoFeed[]> { | 409 | }) { |
596 | const serverActor = await getServerActor() | 410 | const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null }) |
597 | const { start, count, videoId, accountId, videoChannelId } = parameters | ||
598 | 411 | ||
599 | const whereAnd: WhereOptions[] = buildBlockedAccountSQLOptimized( | 412 | const queryOptions: ListVideoCommentsOptions = { |
600 | '"VideoCommentModel"."accountId"', | 413 | ...pick(parameters, [ 'start', 'count', 'accountId', 'videoId', 'videoChannelId' ]), |
601 | [ serverActor.Account.id, '"Video->VideoChannel"."accountId"' ] | ||
602 | ) | ||
603 | 414 | ||
604 | if (accountId) { | 415 | selectType: 'feed', |
605 | whereAnd.push({ | ||
606 | accountId | ||
607 | }) | ||
608 | } | ||
609 | 416 | ||
610 | const accountWhere = { | 417 | sort: '-createdAt', |
611 | [Op.and]: whereAnd | 418 | onPublicVideo: true, |
612 | } | 419 | notDeleted: true, |
613 | |||
614 | const videoChannelWhere = videoChannelId ? { id: videoChannelId } : undefined | ||
615 | 420 | ||
616 | const query = { | 421 | 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 | } | 422 | } |
643 | 423 | ||
644 | if (videoId) query.where['videoId'] = videoId | 424 | return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentOwnerVideoFeed>() |
645 | |||
646 | return VideoCommentModel | ||
647 | .scope([ ScopeNames.WITH_ACCOUNT ]) | ||
648 | .findAll(query) | ||
649 | } | 425 | } |
650 | 426 | ||
651 | static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) { | 427 | static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) { |
652 | const accountWhere = filter.onVideosOfAccount | 428 | const queryOptions: ListVideoCommentsOptions = { |
653 | ? { id: filter.onVideosOfAccount.id } | 429 | selectType: 'comment-only', |
654 | : {} | ||
655 | 430 | ||
656 | const query = { | 431 | accountId: ofAccount.id, |
657 | limit: 1000, | 432 | videoAccountOwnerId: filter.onVideosOfAccount?.id, |
658 | where: { | 433 | |
659 | deletedAt: null, | 434 | notDeleted: true, |
660 | accountId: ofAccount.id | 435 | 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 | } | 436 | } |
682 | 437 | ||
683 | return VideoCommentModel | 438 | return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>() |
684 | .scope([ ScopeNames.WITH_ACCOUNT ]) | ||
685 | .findAll(query) | ||
686 | } | 439 | } |
687 | 440 | ||
688 | static async getStats () { | 441 | static async getStats () { |
@@ -750,9 +503,7 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment | |||
750 | } | 503 | } |
751 | 504 | ||
752 | isOwned () { | 505 | isOwned () { |
753 | if (!this.Account) { | 506 | if (!this.Account) return false |
754 | return false | ||
755 | } | ||
756 | 507 | ||
757 | return this.Account.isOwned() | 508 | return this.Account.isOwned() |
758 | } | 509 | } |
@@ -906,22 +657,15 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment | |||
906 | } | 657 | } |
907 | 658 | ||
908 | private static async buildBlockerAccountIds (options: { | 659 | private static async buildBlockerAccountIds (options: { |
909 | videoId: number | 660 | user: MUserAccountId |
910 | isVideoOwned: boolean | 661 | }): Promise<number[]> { |
911 | user?: MUserAccountId | 662 | const { user } = options |
912 | }) { | ||
913 | const { videoId, user, isVideoOwned } = options | ||
914 | 663 | ||
915 | const serverActor = await getServerActor() | 664 | const serverActor = await getServerActor() |
916 | const blockerAccountIds = [ serverActor.Account.id ] | 665 | const blockerAccountIds = [ serverActor.Account.id ] |
917 | 666 | ||
918 | if (user) blockerAccountIds.push(user.Account.id) | 667 | if (user) blockerAccountIds.push(user.Account.id) |
919 | 668 | ||
920 | if (isVideoOwned) { | ||
921 | const videoOwnerAccount = await AccountModel.loadAccountIdFromVideo(videoId) | ||
922 | if (videoOwnerAccount) blockerAccountIds.push(videoOwnerAccount.id) | ||
923 | } | ||
924 | |||
925 | return blockerAccountIds | 669 | return blockerAccountIds |
926 | } | 670 | } |
927 | } | 671 | } |
diff --git a/server/tests/api/videos/video-comments.ts b/server/tests/api/videos/video-comments.ts index dc47f8a4a..5485b72ec 100644 --- a/server/tests/api/videos/video-comments.ts +++ b/server/tests/api/videos/video-comments.ts | |||
@@ -232,7 +232,8 @@ describe('Test video comments', function () { | |||
232 | await command.addReply({ videoId, toCommentId: threadId2, text: text3 }) | 232 | await command.addReply({ videoId, toCommentId: threadId2, text: text3 }) |
233 | 233 | ||
234 | const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 }) | 234 | const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 }) |
235 | expect(tree.comment.totalReplies).to.equal(tree.comment.totalRepliesFromVideoAuthor + 1) | 235 | expect(tree.comment.totalRepliesFromVideoAuthor).to.equal(1) |
236 | expect(tree.comment.totalReplies).to.equal(2) | ||
236 | }) | 237 | }) |
237 | }) | 238 | }) |
238 | 239 | ||